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};
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        match matching_preset(endpoint).map(|p| p.display_name) {
99            Some(name) => writeln!(out, "  {endpoint}  ({name})")?,
100            None => writeln!(out, "  {endpoint}")?,
101        }
102    }
103    Ok(Exit::Clean)
104}
105
106/// Read a key and store it for the resolved endpoint.
107fn login(console: &mut dyn Console, args: &LoginArgs, path: &Path) -> Result<Exit> {
108    let endpoint = resolve_endpoint(args)?;
109    let mut store = AuthStore::load(path)?;
110
111    if store.get(&endpoint).is_some() {
112        console.say(&format!("Replacing the key stored for {endpoint}."))?;
113    }
114
115    if let Some(url) = matching_preset(&endpoint).and_then(|preset| preset.key_url()) {
116        console.say(&format!("Get a key: {url}"))?;
117    }
118
119    let key = console.ask_secret(&format!("Paste the key for {endpoint}"))?;
120    // An empty paste is a cancellation, not a key. `AuthStore::set` would refuse
121    // it anyway; saying so here is the difference between "you changed your
122    // mind" and "something went wrong".
123    if key.trim().is_empty() {
124        console.say("No key entered; nothing was stored.")?;
125        return Ok(Exit::Clean);
126    }
127
128    store.set(&endpoint, &key)?;
129    store.save(path)?;
130    console.say(&format!("✓ Stored a key for {endpoint}"))?;
131    Ok(Exit::Clean)
132}
133
134/// Forget the key for an endpoint.
135fn logout<W: Write>(out: &mut W, args: &LogoutArgs, path: &Path) -> Result<Exit> {
136    let mut store = AuthStore::load(path)?;
137
138    if !store.remove(&args.endpoint) {
139        writeln!(out, "No key was stored for {}.", args.endpoint)?;
140        return Ok(Exit::Clean);
141    }
142
143    store.save(path)?;
144    writeln!(out, "✓ Forgot the key for {}", args.endpoint)?;
145    Ok(Exit::Clean)
146}
147
148/// The endpoint `login` should use.
149///
150/// `--provider` is resolved through the preset table so the two ways of naming
151/// an endpoint cannot disagree - a user who ran `drep init --provider kimi` and
152/// then `drep auth login --provider kimi` must land on the same key.
153fn resolve_endpoint(args: &LoginArgs) -> Result<String> {
154    if let Some(endpoint) = &args.endpoint {
155        return Ok(endpoint.clone());
156    }
157
158    let Some(name) = &args.provider else {
159        return Err(anyhow!(
160            "name the endpoint with --endpoint, or a preset with --provider"
161        ));
162    };
163
164    let preset = presets::preset(name).ok_or_else(|| anyhow!("unknown provider `{name}`"))?;
165    if matches!(preset.backend, presets::PresetBackend::Codex(_)) {
166        return Err(anyhow!(
167            "the Codex CLI owns ChatGPT subscription authentication; run `codex login` instead (nothing was stored by drep)"
168        ));
169    }
170    preset
171        .endpoint()
172        .map(str::to_owned)
173        .ok_or_else(|| anyhow!("--provider {name} presumes no host; use --endpoint instead"))
174}
175
176/// The preset whose endpoint matches, compared the way the store compares.
177fn matching_preset(endpoint: &str) -> Option<&'static presets::LlmPreset> {
178    let wanted = crate::auth::normalise(endpoint);
179    presets::PRESETS.iter().copied().find(|p| {
180        p.endpoint()
181            .is_some_and(|e| crate::auth::normalise(e) == wanted)
182    })
183}
184
185#[cfg(test)]
186mod tests;