Skip to main content

drep/cli/init/
mod.rs

1//! `drep init` - write `drep.toml` and install the git hooks.
2//!
3//! Two things, in order: point drep at a model, and wire it into the
4//! repository's commit/push flow. This is the only part of drep that can
5//! damage something, which is why every failure mode is spelled out in the
6//! submodules rather than collapsed into a single "best effort" call.
7//!
8//! All output goes through a `&mut dyn std::io::Write` so the command is
9//! testable without spawning a subprocess.
10
11use std::io::Write;
12use std::path::{Path, PathBuf};
13
14use anyhow::{Result, anyhow};
15use clap::{Args, builder::TypedValueParser};
16
17pub mod config_file;
18pub mod gitignore;
19pub mod hooks;
20pub mod presets;
21pub mod wizard;
22
23use crate::Exit;
24use crate::auth;
25use crate::diff;
26
27pub use hooks::HookKind;
28
29#[cfg(test)]
30mod tests;
31
32#[derive(Debug, Args)]
33pub struct InitArgs {
34    /// Repository to install into.
35    #[arg(long, value_name = "DIR", default_value = ".")]
36    pub path: PathBuf,
37
38    /// Which model provider to configure. Omit it for the interactive wizard.
39    //
40    // `Option` rather than a defaulted `String` so the command can tell "not
41    // given" from "given as local", which is what decides whether the wizard
42    // runs at all. A plain comment, not a doc comment: clap renders those as
43    // help text, and the reason a field has its type is not something a user
44    // asked about.
45    #[arg(long, value_parser = provider_parser())]
46    pub provider: Option<String>,
47
48    /// Model name. Defaults to the preset's.
49    #[arg(long)]
50    pub model: Option<String>,
51
52    /// Base URL. Required for `--provider custom`.
53    #[arg(long)]
54    pub endpoint: Option<String>,
55
56    /// Which git hooks to install.
57    #[arg(long, value_enum, default_value_t = HookKind::PrePush)]
58    pub hooks: HookKind,
59
60    /// Overwrite an existing drep.toml or a drep-managed hook.
61    #[arg(long)]
62    pub force: bool,
63
64    /// Leave .gitignore alone.
65    ///
66    /// By default `drep init` adds `drep.toml` to it. The file holds no
67    /// secrets, so this decides whether your provider choice is shared with
68    /// the repository.
69    #[arg(long)]
70    pub no_gitignore: bool,
71
72    /// Never prompt, even on a terminal. For scripts and CI.
73    #[arg(long, conflicts_with = "interactive")]
74    pub non_interactive: bool,
75
76    /// Always prompt, even when stdin is not a terminal. For a wrapper feeding
77    /// answers on stdin.
78    #[arg(long)]
79    pub interactive: bool,
80}
81
82/// Build the `--provider` value parser from [`presets::preset_keys`].
83///
84/// Same pattern `severity_parser` uses for `--fail-on`: the accepted set
85/// comes from the preset table, so `--help` and clap's validator cannot
86/// drift apart from the data that drives them.
87fn provider_parser() -> impl TypedValueParser<Value = String> {
88    use clap::builder::PossibleValuesParser;
89    PossibleValuesParser::new(presets::preset_keys())
90}
91
92/// Run the command, writing to stdout. Returns `Ok(Exit::Clean)` on success
93/// and `Err(_)` on any failure.
94pub async fn run(args: &InitArgs) -> Result<Exit> {
95    let mut out = std::io::stdout().lock();
96    run_with(&mut out, args, &auth::default_path()?).await
97}
98
99/// `run_to`, against a named auth store.
100///
101/// The store path is a parameter for the same reason `check::run_with` takes a
102/// root: it is user-level state outside the repository, and a test that used the
103/// real one would read the developer's own keys - making `key_in_store`, and so
104/// the rendered `drep.toml`, depend on whose machine the suite ran on. It would
105/// also write to it.
106pub async fn run_with<W: Write>(out: &mut W, args: &InitArgs, auth_path: &Path) -> Result<Exit> {
107    let toplevel = match diff::run_git(&args.path, &["rev-parse", "--show-toplevel"]).await {
108        Ok(s) => s,
109        Err(err) => {
110            return Err(anyhow!(
111                "{} is not inside a git repository: {err}",
112                args.path.display(),
113            ));
114        }
115    };
116    let root = PathBuf::from(toplevel);
117
118    // Read before anything is written, so a broken store fails the command
119    // rather than half-applying it.
120    let store = auth::AuthStore::load(auth_path)?;
121
122    let interactive = is_interactive(args);
123
124    // Settled *before* a single question is asked, because refusing afterwards
125    // half-applies the run: the wizard's own side effect is storing the pasted
126    // key, and that happens before the config is written. Asking seven
127    // questions, saving a credential and then failing on "drep.toml already
128    // exists" leaves the store changed, the config not, and the provider not
129    // switched.
130    let force = {
131        let mut console = wizard::Terminal::new(out);
132        match existing_config(&root, args, interactive, &mut console)? {
133            Some(force) => force,
134            None => return Ok(Exit::Clean),
135        }
136    };
137
138    let plan = if interactive {
139        // Constructed here rather than inside the wizard for the same reason
140        // `auth_path` is a parameter: a function that resolves its own path
141        // reads the environment, and nothing can then test it without writing
142        // to the process environment. Only this branch touches either, which is
143        // what keeps the flag path - and every test of it - off the network.
144        let quirks = crate::llm::quirks::Cached::new(crate::llm::quirks::default_path());
145        let models = crate::llm::models::Http::new();
146        let mut console = wizard::Terminal::new(out);
147        wizard::run(
148            &mut console,
149            wizard::Deps {
150                store: &store,
151                source: &models,
152                quirks_source: &quirks,
153                env_is_set: &wizard::real_env,
154                codex_status: &crate::llm::codex::current_status,
155            },
156        )
157        .await?
158    } else {
159        plan_from_flags(args, &store)?
160    };
161
162    // The interactive answer authorises replacing *the config*, which is what it
163    // asked about. Hooks take the explicit `--force` only: `hooks::install`
164    // already refreshes drep's own hook without it and refuses only a foreign
165    // one, so passing the config's answer here would let "Replace drep.toml?"
166    // silently clobber a hook somebody else wrote.
167    apply(out, &root, plan, store, auth_path, force, args.force).await?;
168
169    Ok(Exit::Clean)
170}
171
172/// Decide what to do about a `drep.toml` that is already there.
173///
174/// Returns `Some(force)` to continue - `force` being whether the write may
175/// replace the file - or `None` to stop having changed nothing.
176///
177/// The non-interactive answer is the one `init` has always given: refuse and
178/// name `--force`. Scripts depend on that, and a script has nobody to ask.
179/// Interactively the file is *shown* first, because "replace it?" is not a
180/// question anyone can answer without knowing what is currently configured.
181pub(crate) fn existing_config(
182    root: &Path,
183    args: &InitArgs,
184    interactive: bool,
185    console: &mut dyn wizard::Console,
186) -> Result<Option<bool>> {
187    let path = root.join(crate::config::default_config_path());
188    if args.force || !path.exists() {
189        return Ok(Some(args.force));
190    }
191
192    if !interactive {
193        return Err(config_file::already_exists(&path));
194    }
195
196    console.say(&format!("{} already configures:", path.display()))?;
197    for line in describe(&path) {
198        console.say(&format!("  {line}"))?;
199    }
200    console.say("")?;
201
202    if wizard::confirm(console, "Replace it?", false)? {
203        return Ok(Some(true));
204    }
205
206    console.say("Left unchanged. `drep auth login` rotates a key without touching this file.")?;
207    Ok(None)
208}
209
210/// One line per provider in an existing config, for the replace prompt.
211///
212/// Deliberately tolerant: this is describing a file to a user who is about to
213/// overwrite it, so an unreadable or unparseable one must still let them say
214/// yes rather than turning the prompt into an error about a file they are
215/// discarding anyway.
216pub(crate) fn describe(path: &Path) -> Vec<String> {
217    let Ok(raw) = std::fs::read_to_string(path) else {
218        return vec!["(could not be read)".to_string()];
219    };
220    // `toml::from_str::<Value>` and `raw.parse::<Value>()` are NOT
221    // interchangeable in toml 1.x despite producing the same type: the former
222    // runs the document parser, the latter runs `ValueDeserializer`, which
223    // parses a single TOML *value* and rejects a whole document. Getting this
224    // wrong reported every well-formed config as unparseable.
225    let Ok(value) = toml::from_str::<toml::Value>(&raw) else {
226        return vec!["(could not be parsed)".to_string()];
227    };
228    let Some(entries) = value.get("llm").and_then(toml::Value::as_array) else {
229        return vec!["(no [[llm]] provider)".to_string()];
230    };
231    if entries.is_empty() {
232        return vec!["(no [[llm]] provider)".to_string()];
233    }
234
235    entries
236        .iter()
237        .map(|entry| {
238            let field = |name: &str| {
239                entry
240                    .get(name)
241                    .and_then(toml::Value::as_str)
242                    .unwrap_or("(unset)")
243                    .to_string()
244            };
245            let disabled = match entry.get("enabled").and_then(toml::Value::as_bool) {
246                Some(false) => " (disabled)",
247                _ => "",
248            };
249            if entry.get("backend").and_then(toml::Value::as_str) == Some("codex") {
250                format!(
251                    "{} via ChatGPT/Codex subscription{disabled}",
252                    field("model")
253                )
254            } else {
255                format!("{} at {}{disabled}", field("model"), field("endpoint"))
256            }
257        })
258        .collect()
259}
260
261/// Whether to run the wizard.
262///
263/// Interactive when a person is at the other end and has not already said which
264/// provider they want. `--provider` is the escape hatch that keeps every
265/// existing scripted invocation working unchanged, and `--non-interactive`
266/// covers the case of a script that wants the defaults without naming one.
267///
268/// The terminal check matters on its own: a hook, a CI job or a piped
269/// invocation has no stdin to answer with, and prompting there would hang the
270/// command rather than fail it.
271fn is_interactive(args: &InitArgs) -> bool {
272    use std::io::IsTerminal;
273    wants_wizard(args, std::io::stdin().is_terminal())
274}
275
276/// The decision itself, with the terminal check as a parameter.
277///
278/// Split out because `std::io::stdin().is_terminal()` is *always false* under
279/// `cargo test` - the harness captures stdin - so every combination of these
280/// flags collapses to the same answer in-process and no unit test can tell the
281/// conditions apart. The wiring to the real terminal is covered by an
282/// integration test that runs the binary with a piped stdin; everything else is
283/// covered here.
284pub(crate) fn wants_wizard(args: &InitArgs, stdin_is_terminal: bool) -> bool {
285    // Explicit beats inference, in both directions.
286    if args.interactive {
287        return true;
288    }
289    if args.non_interactive {
290        return false;
291    }
292    // Naming a provider is answering the wizard's first question, so there is
293    // nothing left to ask that a flag has not already said.
294    args.provider.is_none() && stdin_is_terminal
295}
296
297/// Build the plan from flags alone, the way `init` always worked.
298///
299/// `--provider` defaults to `local` here rather than on the argument, because
300/// the argument has to stay `None`-able for [`is_interactive`] to read.
301pub(crate) fn plan_from_flags(args: &InitArgs, store: &auth::AuthStore) -> Result<wizard::Plan> {
302    let provider = args.provider.as_deref().unwrap_or("local");
303    let preset =
304        presets::preset(provider).ok_or_else(|| anyhow!("unknown provider `{provider}`"))?;
305
306    let endpoint = match &preset.backend {
307        presets::PresetBackend::Codex(_) => None,
308        presets::PresetBackend::Http(http) => Some(
309            args.endpoint
310                .clone()
311                .or_else(|| http.endpoint.map(str::to_owned))
312                .ok_or_else(|| {
313                    anyhow!(
314                        "--provider {} needs an --endpoint (it presumes no host)",
315                        preset.key
316                    )
317                })?,
318        ),
319    };
320
321    let model = args
322        .model
323        .clone()
324        .or_else(|| preset.default_model.map(str::to_owned))
325        .ok_or_else(|| anyhow!("--provider {} needs a --model", preset.key))?;
326
327    Ok(wizard::Plan {
328        // A key already stored for this endpoint is used, and the `${VAR}` line
329        // omitted - an explicit `api_key` would otherwise override the very key
330        // the user saved, with a variable they may never have exported.
331        choices: vec![match endpoint {
332            Some(endpoint) => {
333                let key_in_store = store.get(&endpoint).is_some();
334                // The preset's own values, unnarrowed: this path has no
335                // prompt, so it makes no network call either.
336                config_file::Choice::http(preset, model, endpoint, key_in_store, preset.quirks())
337            }
338            None => config_file::Choice::codex(preset, model),
339        }],
340        new_keys: Vec::new(),
341        hooks: args.hooks,
342        gitignore: !args.no_gitignore,
343    })
344}
345
346/// Carry out a plan: store keys, write the config, install hooks, edit
347/// `.gitignore`.
348///
349/// Keys first. A `drep.toml` naming no `api_key` is only correct once the store
350/// holds one, so writing the config first would leave a window - and, if the
351/// store write then failed, a config that authenticates as nothing with no
352/// indication why.
353async fn apply<W: Write>(
354    out: &mut W,
355    root: &Path,
356    plan: wizard::Plan,
357    mut store: auth::AuthStore,
358    auth_path: &Path,
359    config_force: bool,
360    hooks_force: bool,
361) -> Result<()> {
362    if !plan.new_keys.is_empty() {
363        for (endpoint, key) in &plan.new_keys {
364            store.set(endpoint, key)?;
365        }
366        store.save(auth_path)?;
367        writeln!(out)?;
368        writeln!(
369            out,
370            "✓ Stored {} key(s) for this machine (`drep auth list` to review)",
371            plan.new_keys.len()
372        )?;
373    }
374
375    let path = config_file::write(
376        root,
377        &config_file::render_chain(&plan.choices),
378        config_force,
379    )?;
380
381    let summary = plan
382        .choices
383        .iter()
384        .map(|c| format!("{} ({})", c.preset.display_name, c.model))
385        .collect::<Vec<_>>()
386        .join(", then ");
387    writeln!(out, "✓ Wrote {} - {summary}", path.display())?;
388
389    if plan.gitignore {
390        gitignore::ensure_to(out, root).await?;
391    }
392
393    hooks::install(out, root, plan.hooks, hooks_force).await?;
394
395    // Every variable this config depends on, named whether or not it is set.
396    // Naming it unconditionally is the point: the report is what tells a user
397    // which variable this provider reads, and suppressing it once the variable
398    // happens to be exported would hide that from the person most likely to
399    // need it later. Whether it is *currently* set is the second column.
400    //
401    // Only providers whose key is not in the store appear: for those, the
402    // rendered block carries no `api_key` line and the environment is never
403    // consulted.
404    let mut needed: Vec<&str> = Vec::new();
405    for var in plan
406        .choices
407        .iter()
408        .filter(|choice| choice.is_http() && !choice.key_in_store())
409        .filter_map(|choice| choice.preset.api_key_env())
410    {
411        if !needed.contains(&var) {
412            needed.push(var);
413        }
414    }
415
416    if !needed.is_empty() {
417        writeln!(out)?;
418        writeln!(out, "This config reads its key from the environment:")?;
419        for var in needed {
420            match std::env::var_os(var) {
421                Some(_) => writeln!(out, "  {var} - already set")?,
422                None => writeln!(out, "  {var} - NOT set; export it before running drep")?,
423            }
424        }
425    }
426
427    Ok(())
428}