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 despite producing the same type: the former runs the
222 // document parser, while 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}