Skip to content

Commit 60f3daf

Browse files
authored
feat: add auth command to CLI for authentication management (#98)
* feat: add `auth` command to CLI for authentication management and introduce new monitoring and testing scripts. * feat: Add `nblm.login()` Python function and `drive_access` option to CLI and Python authentication, along with new test and monitoring scripts. * feat: implement special command parsing for 'doctor' and 'auth' in CLI - Added `SpecialCommand` enum to handle specific commands bypassing the main CLI initialization. - Introduced `parse_pre_command` function to parse command-line arguments for 'doctor' and 'auth' commands. - Refactored the main function to utilize the new command parsing logic, improving command handling. - Added unit tests for `parse_pre_command` to ensure correct behavior for different command scenarios. - Created a `build_login_command` function in the auth module to streamline command construction for authentication.\ * chore: bump package versions to 0.2.3 for nblm-cli, nblm-core, nblm-python, and related Python project files
1 parent 562fdac commit 60f3daf

File tree

19 files changed

+472
-38
lines changed

19 files changed

+472
-38
lines changed

crates/nblm-cli/Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "nblm-cli"
3-
version = "0.2.2"
3+
version = "0.2.3"
44
edition = "2021"
55
license = "MIT"
66
description = "Command-line interface for NotebookLM Enterprise API"
@@ -28,7 +28,7 @@ tracing-subscriber = { version = "0.3.20", features = [
2828
"fmt",
2929
"json",
3030
] }
31-
nblm-core = { version = "0.2.2", path = "../nblm-core" }
31+
nblm-core = { version = "0.2.3", path = "../nblm-core" }
3232
humantime = "2.3.0"
3333
url = "2.5.7"
3434
mime_guess = "2.0.5"

crates/nblm-cli/src/app.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ impl NblmApp {
7070
Command::Notebooks(cmd) => notebooks::run(cmd, &client, json_mode).await,
7171
Command::Sources(cmd) => sources::run(cmd, &client, json_mode).await,
7272
Command::Audio(cmd) => audio::run(cmd, &client, json_mode).await,
73+
Command::Auth(cmd) => crate::ops::auth::run(cmd).await,
7374
Command::Doctor(cmd) => doctor::run(cmd).await,
7475
}
7576
}

crates/nblm-cli/src/args.rs

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,32 @@ pub enum Command {
7070
Sources(ops::sources::Command),
7171
#[command(subcommand)]
7272
Audio(ops::audio::Command),
73+
/// Manage authentication using Google Cloud SDK (gcloud)
74+
Auth(AuthCommand),
7375
Doctor(ops::doctor::DoctorArgs),
7476
}
7577

78+
#[derive(Args)]
79+
pub struct AuthCommand {
80+
#[command(subcommand)]
81+
pub command: AuthSubcommand,
82+
}
83+
84+
#[derive(Subcommand)]
85+
pub enum AuthSubcommand {
86+
/// Log in via Google Cloud SDK (gcloud auth login)
87+
Login(LoginArgs),
88+
/// Check current authentication status
89+
Status,
90+
}
91+
92+
#[derive(Args)]
93+
pub struct LoginArgs {
94+
/// Request Google Drive access (adds --enable-gdrive-access to gcloud)
95+
#[arg(long)]
96+
pub drive_access: bool,
97+
}
98+
7699
#[derive(Copy, Clone, ValueEnum)]
77100
pub enum AuthMethod {
78101
Gcloud,
@@ -113,3 +136,122 @@ impl From<ProfileArg> for ApiProfile {
113136
}
114137
}
115138
}
139+
140+
pub enum SpecialCommand {
141+
Doctor(crate::ops::doctor::DoctorArgs),
142+
Auth(AuthCommand),
143+
}
144+
145+
pub fn parse_pre_command(args: &[String]) -> Option<SpecialCommand> {
146+
if args.len() <= 1 {
147+
return None;
148+
}
149+
150+
match args[1].as_str() {
151+
"doctor" => {
152+
#[derive(Parser)]
153+
#[command(name = "nblm")]
154+
struct DoctorCli {
155+
#[command(subcommand)]
156+
command: DoctorCommand,
157+
}
158+
159+
#[derive(Subcommand)]
160+
enum DoctorCommand {
161+
Doctor(crate::ops::doctor::DoctorArgs),
162+
}
163+
164+
// We use try_parse_from to avoid exiting the process on error/help
165+
// But for main logic we might want to just parse.
166+
// Here we are replicating main.rs logic which assumes if "doctor" is present
167+
// we treat it as doctor command.
168+
// However, main.rs used `parse()` which exits.
169+
// To keep it testable, we should probably use `try_parse_from`.
170+
// But `main.rs` logic was: if args[1] == "doctor", parse as DoctorCli.
171+
172+
// If parsing fails (e.g. --help), we might want to let main handle it or return None?
173+
// In main.rs, it called `parse()`, so it would exit.
174+
// For exact behavior preservation:
175+
let cli = DoctorCli::parse_from(args);
176+
let DoctorCommand::Doctor(args) = cli.command;
177+
Some(SpecialCommand::Doctor(args))
178+
}
179+
"auth" => {
180+
#[derive(Parser)]
181+
#[command(name = "nblm")]
182+
struct AuthCli {
183+
#[command(subcommand)]
184+
command: AuthCommandWrapper,
185+
}
186+
187+
#[derive(Subcommand)]
188+
enum AuthCommandWrapper {
189+
Auth(AuthCommand),
190+
}
191+
192+
let cli = AuthCli::parse_from(args);
193+
let AuthCommandWrapper::Auth(cmd) = cli.command;
194+
Some(SpecialCommand::Auth(cmd))
195+
}
196+
_ => None,
197+
}
198+
}
199+
200+
#[cfg(test)]
201+
mod tests {
202+
use super::*;
203+
204+
#[test]
205+
fn test_parse_pre_command() {
206+
// Test doctor
207+
let args = vec!["nblm".to_string(), "doctor".to_string()];
208+
match parse_pre_command(&args) {
209+
Some(SpecialCommand::Doctor(_)) => {}
210+
_ => panic!("expected Doctor command"),
211+
}
212+
213+
// Test auth
214+
let args = vec!["nblm".to_string(), "auth".to_string(), "login".to_string()];
215+
match parse_pre_command(&args) {
216+
Some(SpecialCommand::Auth(cmd)) => match cmd.command {
217+
AuthSubcommand::Login(_) => {}
218+
_ => panic!("expected Login subcommand"),
219+
},
220+
_ => panic!("expected Auth command"),
221+
}
222+
223+
// Test normal command
224+
let args = vec!["nblm".to_string(), "notebooks".to_string()];
225+
assert!(parse_pre_command(&args).is_none());
226+
}
227+
228+
#[test]
229+
fn parse_auth_command() {
230+
let args = Cli::parse_from(["nblm", "auth", "login"]);
231+
match args.command {
232+
Command::Auth(cmd) => match cmd.command {
233+
AuthSubcommand::Login(_) => {}
234+
_ => panic!("expected Login subcommand"),
235+
},
236+
_ => panic!("expected Auth command"),
237+
}
238+
239+
let args = Cli::parse_from(["nblm", "auth", "login", "--drive-access"]);
240+
match args.command {
241+
Command::Auth(cmd) => match cmd.command {
242+
AuthSubcommand::Login(args) => assert!(args.drive_access),
243+
_ => panic!("expected Login subcommand"),
244+
},
245+
_ => panic!("expected Auth command"),
246+
}
247+
248+
let args = Cli::parse_from(["nblm", "auth", "status"]);
249+
match args.command {
250+
Command::Auth(cmd) => match cmd.command {
251+
AuthSubcommand::Status => {}
252+
_ => panic!("expected Status subcommand"),
253+
},
254+
_ => panic!("expected Auth command"),
255+
}
256+
}
257+
}

crates/nblm-cli/src/main.rs

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,24 +18,12 @@ async fn main() -> Result<()> {
1818
anyhow::bail!("The --json flag is not supported for the 'doctor' command");
1919
}
2020

21-
if args.len() > 1 && args[1] == "doctor" {
22-
// Parse doctor-specific arguments
23-
use clap::Parser;
24-
#[derive(Parser)]
25-
#[command(name = "nblm")]
26-
struct DoctorCli {
27-
#[command(subcommand)]
28-
command: DoctorCommand,
21+
// Check for special commands that need to bypass NblmApp initialization
22+
if let Some(cmd) = args::parse_pre_command(&args) {
23+
match cmd {
24+
args::SpecialCommand::Doctor(args) => return ops::doctor::run(args).await,
25+
args::SpecialCommand::Auth(cmd) => return ops::auth::run(cmd).await,
2926
}
30-
31-
#[derive(clap::Subcommand)]
32-
enum DoctorCommand {
33-
Doctor(ops::doctor::DoctorArgs),
34-
}
35-
36-
let doctor_cli = DoctorCli::parse();
37-
let DoctorCommand::Doctor(doctor_args) = doctor_cli.command;
38-
return ops::doctor::run(doctor_args).await;
3927
}
4028

4129
let cli = args::Cli::parse();

crates/nblm-cli/src/ops/auth.rs

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
use anyhow::{Context, Result};
2+
use colored::Colorize;
3+
use std::process::Stdio;
4+
use tokio::process::Command;
5+
6+
use crate::args::{AuthCommand, AuthSubcommand};
7+
8+
pub async fn run(cmd: AuthCommand) -> Result<()> {
9+
match cmd.command {
10+
AuthSubcommand::Login(args) => login(args).await,
11+
AuthSubcommand::Status => status().await,
12+
}
13+
}
14+
15+
async fn login(args: crate::args::LoginArgs) -> Result<()> {
16+
println!("{}", "Starting Google Cloud authentication...".cyan());
17+
println!("This will open your browser to authenticate with Google.");
18+
19+
let mut command = build_login_command(&args);
20+
21+
println!("(Executing: {:?})\n", command);
22+
23+
let status = command
24+
.stdin(Stdio::inherit())
25+
.stdout(Stdio::inherit())
26+
.stderr(Stdio::inherit())
27+
.status()
28+
.await
29+
.context("Failed to execute 'gcloud'. Please ensure Google Cloud SDK is installed and in your PATH.")?;
30+
31+
if status.success() {
32+
println!("\n{}", "Authentication successful!".green().bold());
33+
println!("You can now use nblm commands.");
34+
} else {
35+
println!("\n{}", "Authentication failed.".red().bold());
36+
if let Some(code) = status.code() {
37+
println!("gcloud exited with code: {}", code);
38+
}
39+
anyhow::bail!("gcloud auth login failed");
40+
}
41+
42+
Ok(())
43+
}
44+
45+
async fn status() -> Result<()> {
46+
// Check if we can get a token
47+
let output = Command::new("gcloud")
48+
.arg("auth")
49+
.arg("print-access-token")
50+
.output()
51+
.await
52+
.context("Failed to execute 'gcloud'. Please ensure Google Cloud SDK is installed.")?;
53+
54+
if !output.status.success() {
55+
println!("{}", "Not authenticated.".yellow());
56+
println!("Run '{}' to log in.", "nblm auth login".bold());
57+
anyhow::bail!("Not authenticated");
58+
}
59+
60+
// Try to get the current account email for better status info
61+
let account_output = Command::new("gcloud")
62+
.arg("config")
63+
.arg("get-value")
64+
.arg("account")
65+
.output()
66+
.await;
67+
68+
let account = if let Ok(out) = account_output {
69+
String::from_utf8_lossy(&out.stdout).trim().to_string()
70+
} else {
71+
"Unknown account".to_string()
72+
};
73+
74+
println!("{}", "Authenticated".green().bold());
75+
if !account.is_empty() {
76+
println!("Account: {}", account.cyan());
77+
}
78+
println!("Backend: gcloud");
79+
80+
Ok(())
81+
}
82+
83+
fn build_login_command(args: &crate::args::LoginArgs) -> Command {
84+
let mut command = Command::new("gcloud");
85+
command.arg("auth").arg("login");
86+
87+
if args.drive_access {
88+
println!("(Requesting Google Drive access)");
89+
command.arg("--enable-gdrive-access");
90+
}
91+
command
92+
}
93+
94+
#[cfg(test)]
95+
mod tests {
96+
use super::*;
97+
use crate::args::LoginArgs;
98+
99+
#[test]
100+
fn test_build_login_command_default() {
101+
let args = LoginArgs {
102+
drive_access: false,
103+
};
104+
let cmd = build_login_command(&args);
105+
let cmd_str = format!("{:?}", cmd);
106+
assert!(cmd_str.contains("gcloud"));
107+
assert!(cmd_str.contains("auth"));
108+
assert!(cmd_str.contains("login"));
109+
assert!(!cmd_str.contains("--enable-gdrive-access"));
110+
}
111+
112+
#[test]
113+
fn test_build_login_command_drive_access() {
114+
let args = LoginArgs { drive_access: true };
115+
let cmd = build_login_command(&args);
116+
let cmd_str = format!("{:?}", cmd);
117+
assert!(cmd_str.contains("--enable-gdrive-access"));
118+
}
119+
}

crates/nblm-cli/src/ops/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
pub mod audio;
2+
pub mod auth;
23
pub mod doctor;
34
pub mod notebooks;
45
pub mod sources;

crates/nblm-core/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "nblm-core"
3-
version = "0.2.2"
3+
version = "0.2.3"
44
edition = "2021"
55
license = "MIT"
66
description = "Core library for NotebookLM Enterprise API client"

crates/nblm-python/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "nblm-python"
3-
version = "0.2.2"
3+
version = "0.2.3"
44
edition = "2021"
55
license = "MIT"
66
description = "Python bindings for nblm-core (NotebookLM Enterprise API client)"

0 commit comments

Comments
 (0)