Skip to main content

drep/cli/
auth.rs

1//! `drep auth` - manage the keys drep holds for this machine.
2//!
3//! The store is written by `drep init` as a side effect of setting a provider
4//! up. These subcommands exist for everything after that: rotating a key,
5//! adding one for an endpoint you configured by hand, checking what is held,
6//! and removing one.
7//!
8//! **No subcommand ever prints a key.** `list` prints endpoints, `login` reads
9//! one without echoing it, and `logout` reports only whether anything was
10//! removed. A `drep auth show` would be the obvious convenience and is
11//! deliberately absent: the store is a file, and anyone who genuinely needs the
12//! value can read it, having chosen to.
13
14use std::io::Write;
15use std::path::Path;
16
17use anyhow::{Result, anyhow};
18use clap::{Args, Subcommand};
19
20use crate::Exit;
21use crate::auth::{AuthStore, default_path, display_endpoint};
22use crate::cli::init::presets;
23use crate::cli::init::wizard::{Console, Terminal};
24
25#[derive(Debug, Args)]
26pub struct AuthArgs {
27    #[command(subcommand)]
28    pub command: AuthCommand,
29}
30
31#[derive(Debug, Subcommand)]
32pub enum AuthCommand {
33    /// List the endpoints with a stored key. Never prints the keys.
34    List,
35    /// Store a key for an endpoint, reading it without echoing.
36    Login(LoginArgs),
37    /// Forget the key stored for an endpoint.
38    Logout(LogoutArgs),
39}
40
41#[derive(Debug, Args)]
42pub struct LoginArgs {
43    /// The endpoint the key authenticates.
44    ///
45    /// Mutually exclusive with `--provider`, which supplies one from the preset
46    /// table.
47    #[arg(long, conflicts_with = "provider")]
48    pub endpoint: Option<String>,
49
50    /// A preset whose endpoint to use, e.g. `kimi`.
51    #[arg(long)]
52    pub provider: Option<String>,
53}
54
55#[derive(Debug, Args)]
56pub struct LogoutArgs {
57    /// The endpoint to forget.
58    #[arg(long)]
59    pub endpoint: String,
60}
61
62/// Run the command, writing to stdout.
63pub fn run(args: &AuthArgs) -> Result<Exit> {
64    let mut out = std::io::stdout().lock();
65    run_at(&mut out, args, &default_path()?)
66}
67
68/// `run_to`, against a named store.
69///
70/// The path is a parameter for the same reason `init::run_with` takes one: the
71/// store is user-level state, and a test using the real one would read and
72/// write the developer's own keys.
73pub fn run_at<W: Write>(out: &mut W, args: &AuthArgs, path: &Path) -> Result<Exit> {
74    match &args.command {
75        AuthCommand::List => list(out, path),
76        AuthCommand::Login(login) => {
77            let mut console = Terminal::new(out);
78            self::login(&mut console, login, path)
79        }
80        AuthCommand::Logout(logout) => self::logout(out, logout, path),
81    }
82}
83
84/// Print the endpoints with a stored key.
85fn list<W: Write>(out: &mut W, path: &Path) -> Result<Exit> {
86    let store = AuthStore::load(path)?;
87
88    if store.is_empty() {
89        writeln!(out, "No keys stored ({}).", path.display())?;
90        writeln!(out, "Run `drep auth login --provider <name>` to add one.")?;
91        return Ok(Exit::Clean);
92    }
93
94    writeln!(out, "Keys stored in {}:", path.display())?;
95    for endpoint in store.endpoints() {
96        // The preset name, when one matches, is what a user actually recognises;
97        // the endpoint alone reads as a URL they half remember configuring.
98        let shown = display_endpoint(endpoint);
99        match matching_preset(endpoint).map(|p| p.display_name) {
100            Some(name) => writeln!(out, "  {shown}  ({name})")?,
101            None => writeln!(out, "  {shown}")?,
102        }
103    }
104    Ok(Exit::Clean)
105}
106
107/// Read a key and store it for the resolved endpoint.
108fn login(console: &mut dyn Console, args: &LoginArgs, path: &Path) -> Result<Exit> {
109    let endpoint = resolve_endpoint(args)?;
110    let shown = display_endpoint(&endpoint);
111    let mut store = AuthStore::load(path)?;
112
113    if store.get(&endpoint).is_some() {
114        console.say(&format!("Replacing the key stored for {shown}."))?;
115    }
116
117    if let Some(url) = matching_preset(&endpoint).and_then(|preset| preset.key_url()) {
118        console.say(&format!("Get a key: {url}"))?;
119    }
120
121    let key = console.ask_secret(&format!("Paste the key for {shown}"))?;
122    // An empty paste is a cancellation, not a key. `AuthStore::set` would refuse
123    // it anyway; saying so here is the difference between "you changed your
124    // mind" and "something went wrong".
125    if key.trim().is_empty() {
126        console.say("No key entered; nothing was stored.")?;
127        return Ok(Exit::Clean);
128    }
129
130    store.set(&endpoint, &key)?;
131    store.save(path)?;
132    console.say(&format!("✓ Stored a key for {shown}"))?;
133    Ok(Exit::Clean)
134}
135
136/// Forget the key for an endpoint.
137fn logout<W: Write>(out: &mut W, args: &LogoutArgs, path: &Path) -> Result<Exit> {
138    let mut store = AuthStore::load(path)?;
139    let shown = display_endpoint(&args.endpoint);
140
141    if !store.remove(&args.endpoint) {
142        writeln!(out, "No key was stored for {shown}.")?;
143        return Ok(Exit::Clean);
144    }
145
146    store.save(path)?;
147    writeln!(out, "✓ Forgot the key for {shown}")?;
148    Ok(Exit::Clean)
149}
150
151/// The endpoint `login` should use.
152///
153/// `--provider` is resolved through the preset table so the two ways of naming
154/// an endpoint cannot disagree - a user who ran `drep init --provider kimi` and
155/// then `drep auth login --provider kimi` must land on the same key.
156fn resolve_endpoint(args: &LoginArgs) -> Result<String> {
157    if let Some(endpoint) = &args.endpoint {
158        return Ok(endpoint.clone());
159    }
160
161    let Some(name) = &args.provider else {
162        return Err(anyhow!(
163            "name the endpoint with --endpoint, or a preset with --provider"
164        ));
165    };
166
167    let preset = presets::preset(name).ok_or_else(|| anyhow!("unknown provider `{name}`"))?;
168    if matches!(preset.backend, presets::PresetBackend::Codex(_)) {
169        return Err(anyhow!(
170            "the Codex CLI owns ChatGPT subscription authentication; run `codex login` instead (nothing was stored by drep)"
171        ));
172    }
173    preset
174        .endpoint()
175        .map(str::to_owned)
176        .ok_or_else(|| anyhow!("--provider {name} presumes no host; use --endpoint instead"))
177}
178
179/// The preset whose endpoint matches, compared the way the store compares.
180fn matching_preset(endpoint: &str) -> Option<&'static presets::LlmPreset> {
181    let wanted = crate::auth::normalise(endpoint);
182    presets::PRESETS.iter().copied().find(|p| {
183        p.endpoint()
184            .is_some_and(|e| crate::auth::normalise(e) == wanted)
185    })
186}
187
188#[cfg(test)]
189mod tests;