From d1dce5547bec21837ca3694ecd84365ee7322e88 Mon Sep 17 00:00:00 2001 From: fer <21882+fer@users.noreply.github.com> Date: Mon, 6 Oct 2025 16:08:16 +0200 Subject: [PATCH 01/12] read env vars for client id/secret password when using api-key and return session token to reuse --- crates/bitwarden-cli/src/lib.rs | 26 +++++++++++++++++ crates/bitwarden-core/src/client/internal.rs | 16 +++++++++++ crates/bw/src/auth/login.rs | 30 +++++++++++++++----- crates/bw/src/auth/mod.rs | 8 ++++-- 4 files changed, 71 insertions(+), 9 deletions(-) diff --git a/crates/bitwarden-cli/src/lib.rs b/crates/bitwarden-cli/src/lib.rs index 8658a254a..7dcaa7f70 100644 --- a/crates/bitwarden-cli/src/lib.rs +++ b/crates/bitwarden-cli/src/lib.rs @@ -15,3 +15,29 @@ pub fn text_prompt_when_none(prompt: &str, val: Option) -> InquireResult Text::new(prompt).prompt()? }) } + +/// Try to get a value from CLI arg, then from environment variables, then prompt +/// +/// Checks multiple environment variable names in order (e.g., BW_CLIENTID, BW_CLIENT_ID) +pub fn text_or_env_prompt( + prompt: &str, + cli_val: Option, + env_var_names: &[&str], +) -> InquireResult { + // First check if provided via CLI + if let Some(val) = cli_val { + return Ok(val); + } + + // Then check environment variables + for env_var in env_var_names { + if let Ok(val) = std::env::var(env_var) { + if !val.is_empty() { + return Ok(val); + } + } + } + + // Finally, prompt the user + Text::new(prompt).prompt() +} diff --git a/crates/bitwarden-core/src/client/internal.rs b/crates/bitwarden-core/src/client/internal.rs index 591041177..5f4dbd5ee 100644 --- a/crates/bitwarden-core/src/client/internal.rs +++ b/crates/bitwarden-core/src/client/internal.rs @@ -285,6 +285,22 @@ impl InternalClient { self.user_id.get().copied() } + /// Export the user encryption key as a base64-encoded session string + /// This is used to return a session key that can be used with --session or BW_SESSION + #[cfg(feature = "internal")] + pub fn export_user_key_as_session(&self) -> Result { + use crate::key_management::SymmetricKeyId; + + #[allow(deprecated)] + let session = { + let ctx = self.key_store.context(); + let user_key = ctx.dangerous_get_symmetric_key(SymmetricKeyId::User)?; + user_key.to_base64().to_string() + }; + + Ok(session) + } + #[cfg(feature = "internal")] pub(crate) fn initialize_user_crypto_master_key( &self, diff --git a/crates/bw/src/auth/login.rs b/crates/bw/src/auth/login.rs index c5d50af38..9c90c0a49 100644 --- a/crates/bw/src/auth/login.rs +++ b/crates/bw/src/auth/login.rs @@ -1,4 +1,4 @@ -use bitwarden_cli::text_prompt_when_none; +use bitwarden_cli::{text_or_env_prompt, text_prompt_when_none}; use bitwarden_core::{ Client, auth::login::{ @@ -93,11 +93,24 @@ pub(crate) async fn login_api_key( client: Client, client_id: Option, client_secret: Option, -) -> Result<()> { - let client_id = text_prompt_when_none("Client ID", client_id)?; - let client_secret = text_prompt_when_none("Client Secret", client_secret)?; - - let password = Password::new("Password").without_confirmation().prompt()?; +) -> Result { + let client_id = text_or_env_prompt("Client ID", client_id, &["BW_CLIENTID", "BW_CLIENT_ID"])?; + let client_secret = text_or_env_prompt( + "Client Secret", + client_secret, + &["BW_CLIENTSECRET", "BW_CLIENT_SECRET"], + )?; + + // Check for password in environment variable first + let password = if let Ok(pwd) = std::env::var("BW_PASSWORD") { + if !pwd.is_empty() { + pwd + } else { + Password::new("Password").without_confirmation().prompt()? + } + } else { + Password::new("Password").without_confirmation().prompt()? + }; let result = client .auth() @@ -110,7 +123,10 @@ pub(crate) async fn login_api_key( debug!("{result:?}"); - Ok(()) + // Export the user key as a session string + let session = client.internal.export_user_key_as_session()?; + + Ok(session) } pub(crate) async fn login_device( diff --git a/crates/bw/src/auth/mod.rs b/crates/bw/src/auth/mod.rs index d790698cf..d5963a76d 100644 --- a/crates/bw/src/auth/mod.rs +++ b/crates/bw/src/auth/mod.rs @@ -47,19 +47,23 @@ impl LoginArgs { // FIXME: Rust CLI will not support password login! LoginCommands::Password { email } => { login::login_password(client, email).await?; + Ok("Successfully logged in!".into()) } LoginCommands::ApiKey { client_id, client_secret, - } => login::login_api_key(client, client_id, client_secret).await?, + } => { + let session = login::login_api_key(client, client_id, client_secret).await?; + Ok(session.into()) + } LoginCommands::Device { email, device_identifier, } => { login::login_device(client, email, device_identifier).await?; + Ok("Successfully logged in!".into()) } } - Ok("Successfully logged in!".into()) } } From 96a3dc6b2c6efa40eee3176b80e7a14642dcf90a Mon Sep 17 00:00:00 2001 From: fer <21882+fer@users.noreply.github.com> Date: Mon, 6 Oct 2025 21:24:31 +0200 Subject: [PATCH 02/12] Add session export/import with full crypto state --- crates/bitwarden-core/src/client/internal.rs | 114 +++++++++++++++++-- crates/bw/src/auth/login.rs | 13 ++- 2 files changed, 118 insertions(+), 9 deletions(-) diff --git a/crates/bitwarden-core/src/client/internal.rs b/crates/bitwarden-core/src/client/internal.rs index 5f4dbd5ee..83cf3042b 100644 --- a/crates/bitwarden-core/src/client/internal.rs +++ b/crates/bitwarden-core/src/client/internal.rs @@ -285,20 +285,120 @@ impl InternalClient { self.user_id.get().copied() } - /// Export the user encryption key as a base64-encoded session string - /// This is used to return a session key that can be used with --session or BW_SESSION + /// Export a full session containing all data needed to restore the client state + /// This includes the user key, tokens, and encrypted private/signing keys #[cfg(feature = "internal")] - pub fn export_user_key_as_session(&self) -> Result { - use crate::key_management::SymmetricKeyId; + pub fn export_session(&self) -> Result { + use bitwarden_encoding::B64; + use crate::key_management::{AsymmetricKeyId, SymmetricKeyId}; + use serde::{Deserialize, Serialize}; + + #[derive(Serialize, Deserialize)] + struct SessionData { + user_key: String, + private_key: Option, + access_token: Option, + refresh_token: Option, + expires_on: Option, + } + // Get the user encryption key and private key #[allow(deprecated)] - let session = { + let (user_key, private_key) = { let ctx = self.key_store.context(); let user_key = ctx.dangerous_get_symmetric_key(SymmetricKeyId::User)?; - user_key.to_base64().to_string() + let private_key = if ctx.has_asymmetric_key(AsymmetricKeyId::UserPrivateKey) { + let key = ctx.dangerous_get_asymmetric_key(AsymmetricKeyId::UserPrivateKey)?; + Some(B64::from(key.to_der()?.as_ref()).to_string()) + } else { + None + }; + (user_key.to_base64().to_string(), private_key) + }; + + // Get the tokens + let tokens = self.tokens.read().expect("RwLock is not poisoned"); + let (access_token, refresh_token, expires_on) = match &*tokens { + Tokens::SdkManaged(sdk_tokens) => ( + sdk_tokens.access_token.clone(), + sdk_tokens.refresh_token.clone(), + sdk_tokens.expires_on, + ), + Tokens::ClientManaged(_) => (None, None, None), }; - Ok(session) + let session_data = SessionData { + user_key, + private_key, + access_token, + refresh_token, + expires_on, + }; + + // Serialize to JSON and then base64 encode + let json = serde_json::to_string(&session_data) + .map_err(|_| CryptoError::InvalidKey)?; + let encoded = bitwarden_encoding::B64::from(json.as_bytes()); + + Ok(encoded.to_string()) + } + + /// Import a session and restore the client state + /// This includes restoring the user key, private key, and setting tokens + #[cfg(feature = "internal")] + pub fn import_session(&self, session: &str) -> Result<(), CryptoError> { + use bitwarden_crypto::{AsymmetricCryptoKey, Pkcs8PrivateKeyBytes}; + use bitwarden_encoding::B64; + use crate::key_management::{AsymmetricKeyId, SymmetricKeyId}; + use serde::{Deserialize, Serialize}; + + #[derive(Serialize, Deserialize)] + struct SessionData { + user_key: String, + private_key: Option, + access_token: Option, + refresh_token: Option, + expires_on: Option, + } + + // Decode from base64 and parse JSON + let decoded = B64::try_from(session.to_string()) + .map_err(|_| CryptoError::InvalidKey)?; + let json_str = String::from_utf8(decoded.as_bytes().to_vec()) + .map_err(|_| CryptoError::InvalidKey)?; + let session_data: SessionData = serde_json::from_str(&json_str) + .map_err(|_| CryptoError::InvalidKey)?; + + // Restore the user key and private key + let user_key = SymmetricCryptoKey::try_from(session_data.user_key)?; + + #[allow(deprecated)] + { + let mut ctx = self.key_store.context_mut(); + ctx.set_symmetric_key(SymmetricKeyId::User, user_key)?; + + // Restore private key if present + if let Some(private_key_b64) = session_data.private_key { + let private_key_b64_parsed = B64::try_from(private_key_b64) + .map_err(|_| CryptoError::InvalidKey)?; + let private_key_der = Pkcs8PrivateKeyBytes::from(private_key_b64_parsed.as_bytes()); + let private_key = AsymmetricCryptoKey::from_der(&private_key_der)?; + ctx.set_asymmetric_key(AsymmetricKeyId::UserPrivateKey, private_key)?; + } + } + + // Restore the tokens + if let Some(access_token) = session_data.access_token { + *self.tokens.write().expect("RwLock is not poisoned") = + Tokens::SdkManaged(SdkManagedTokens { + access_token: Some(access_token.clone()), + refresh_token: session_data.refresh_token, + expires_on: session_data.expires_on, + }); + self.set_api_tokens_internal(access_token); + } + + Ok(()) } #[cfg(feature = "internal")] diff --git a/crates/bw/src/auth/login.rs b/crates/bw/src/auth/login.rs index 9c90c0a49..c4aa4c881 100644 --- a/crates/bw/src/auth/login.rs +++ b/crates/bw/src/auth/login.rs @@ -123,8 +123,17 @@ pub(crate) async fn login_api_key( debug!("{result:?}"); - // Export the user key as a session string - let session = client.internal.export_user_key_as_session()?; + // Sync vault data after successful login + let sync_result = client + .vault() + .sync(&SyncRequest { + exclude_subdomains: Some(true), + }) + .await?; + info!("Synced {} ciphers", sync_result.ciphers.len()); + + // Export the full session (user key + tokens) + let session = client.internal.export_session()?; Ok(session) } From a45722c980c307d13ccaaf66b87fef7b12fb8e3d Mon Sep 17 00:00:00 2001 From: fer <21882+fer@users.noreply.github.com> Date: Mon, 6 Oct 2025 21:24:51 +0200 Subject: [PATCH 03/12] Add session import support to CLI --- crates/bw/src/main.rs | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/bw/src/main.rs b/crates/bw/src/main.rs index bc5c201ca..c90592125 100644 --- a/crates/bw/src/main.rs +++ b/crates/bw/src/main.rs @@ -39,7 +39,7 @@ async fn main() -> Result<()> { render_config.render_result(result) } -async fn process_commands(command: Commands, _session: Option) -> CommandResult { +async fn process_commands(command: Commands, session: Option) -> CommandResult { // Try to initialize the client with the session if provided // Ideally we'd have separate clients and this would be an enum, something like: // enum CliClient { @@ -52,6 +52,15 @@ async fn process_commands(command: Commands, _session: Option) -> Comman // to do two matches over the whole command tree let client = bitwarden_pm::PasswordManagerClient::new(None); + // If a session was provided, import it to restore the client state + if let Some(ref session_str) = session { + client + .0 + .internal + .import_session(session_str) + .map_err(|e| color_eyre::eyre::eyre!("Failed to import session: {}", e))?; + } + match command { // Auth commands Commands::Login(args) => args.run().await, @@ -94,7 +103,27 @@ async fn process_commands(command: Commands, _session: Option) -> Comman Commands::Item { command: _ } => todo!(), Commands::Template { command } => command.run(), - Commands::List => todo!(), + Commands::List { + object, + search, + folderid, + collectionid, + organizationid, + trash, + } => { + vault::list( + &client.0, + vault::ListOptions { + object, + search, + folderid, + collectionid, + organizationid, + trash, + }, + ) + .await + } Commands::Get => todo!(), Commands::Create => todo!(), Commands::Edit => todo!(), From f79ed349c38e87beb1b8af12e01ce663a80abd6c Mon Sep 17 00:00:00 2001 From: fer <21882+fer@users.noreply.github.com> Date: Mon, 6 Oct 2025 21:25:06 +0200 Subject: [PATCH 04/12] Implement list items command with filtering --- crates/bw/src/command.rs | 20 ++++++- crates/bw/src/vault/list.rs | 111 ++++++++++++++++++++++++++++++++++++ crates/bw/src/vault/mod.rs | 3 + 3 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 crates/bw/src/vault/list.rs diff --git a/crates/bw/src/command.rs b/crates/bw/src/command.rs index 2f829cb06..91840d9b0 100644 --- a/crates/bw/src/command.rs +++ b/crates/bw/src/command.rs @@ -145,7 +145,25 @@ Notes: // These are the old style action-name commands, to be replaced by name-action commands in the // future #[command(long_about = "List an array of objects from the vault.")] - List, + List { + /// Object type to list (items, folders, collections, etc.) + object: String, + + #[arg(long, help = "Perform a search on the listed objects")] + search: Option, + + #[arg(long, help = "Filter items by folder id")] + folderid: Option, + + #[arg(long, help = "Filter items by collection id")] + collectionid: Option, + + #[arg(long, help = "Filter items by organization id")] + organizationid: Option, + + #[arg(long, help = "Filter items that are deleted and in the trash")] + trash: bool, + }, #[command(long_about = "Get an object from the vault.")] Get, #[command(long_about = "Create an object in the vault.")] diff --git a/crates/bw/src/vault/list.rs b/crates/bw/src/vault/list.rs new file mode 100644 index 000000000..c3500aa9c --- /dev/null +++ b/crates/bw/src/vault/list.rs @@ -0,0 +1,111 @@ +use bitwarden_core::Client; +use bitwarden_vault::{CipherListView, SyncRequest, VaultClientExt}; +use color_eyre::eyre::{Result, bail}; + +use crate::render::CommandOutput; + +#[derive(Debug)] +pub struct ListOptions { + pub object: String, + pub search: Option, + pub folderid: Option, + pub collectionid: Option, + pub organizationid: Option, + pub trash: bool, +} + +pub async fn list(client: &Client, options: ListOptions) -> Result { + match options.object.as_str() { + "items" => list_items(client, options).await, + "folders" => { + bail!("Listing folders is not yet implemented") + } + "collections" => { + bail!("Listing collections is not yet implemented") + } + "organizations" => { + bail!("Listing organizations is not yet implemented") + } + "org-collections" => { + bail!("Listing org-collections is not yet implemented") + } + "org-members" => { + bail!("Listing org-members is not yet implemented") + } + _ => { + bail!( + "Invalid object type '{}'. Valid objects are: items, folders, collections, \ + organizations, org-collections, org-members", + options.object + ) + } + } +} + +async fn list_items(client: &Client, options: ListOptions) -> Result { + // Sync to get the latest vault data + let sync_response = client + .vault() + .sync(&SyncRequest { + exclude_subdomains: Some(true), + }) + .await?; + + // Decrypt the ciphers + let mut cipher_views: Vec = client + .vault() + .ciphers() + .decrypt_list(sync_response.ciphers)?; + + // Apply filters (retaining matching items) + cipher_views.retain(|item| { + // Filter by trash status + if options.trash { + if item.deleted_date.is_none() { + return false; + } + } else if item.deleted_date.is_some() { + return false; + } + + // Filter by folder + if let Some(ref folder_id) = options.folderid { + if item.folder_id.as_ref().map(|id| id.to_string()) != Some(folder_id.clone()) { + return false; + } + } + + // Filter by collection + if let Some(ref collection_id) = options.collectionid { + if !item + .collection_ids + .iter() + .any(|id| id.to_string() == *collection_id) + { + return false; + } + } + + // Filter by organization + if let Some(ref org_id) = options.organizationid { + if item.organization_id.as_ref().map(|id| id.to_string()) != Some(org_id.clone()) { + return false; + } + } + + // Search filter (case-insensitive search in name) + if let Some(ref search_term) = options.search { + let search_lower = search_term.to_lowercase(); + if !item.name.to_lowercase().contains(&search_lower) { + return false; + } + } + + true + }); + + // Sort by name for consistent output + cipher_views.sort_by(|a, b| a.name.cmp(&b.name)); + + Ok(CommandOutput::Object(Box::new(cipher_views))) +} diff --git a/crates/bw/src/vault/mod.rs b/crates/bw/src/vault/mod.rs index 0a3106c90..fb1d9d008 100644 --- a/crates/bw/src/vault/mod.rs +++ b/crates/bw/src/vault/mod.rs @@ -2,6 +2,9 @@ use clap::Subcommand; use crate::render::{CommandOutput, CommandResult}; +pub mod list; +pub use list::{ListOptions, list}; + #[derive(Subcommand, Clone)] pub enum ItemCommands { Get { id: String }, From 6750acd7f8d68cb0a82726f218be8ec4577c7652 Mon Sep 17 00:00:00 2001 From: fer <21882+fer@users.noreply.github.com> Date: Mon, 6 Oct 2025 22:05:04 +0200 Subject: [PATCH 05/12] Fix code formatting Apply cargo fmt to fix import ordering and line wrapping to comply with CI style checks. --- crates/bitwarden-core/src/client/internal.rs | 22 +++++++++----------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/crates/bitwarden-core/src/client/internal.rs b/crates/bitwarden-core/src/client/internal.rs index 83cf3042b..2595fe9b5 100644 --- a/crates/bitwarden-core/src/client/internal.rs +++ b/crates/bitwarden-core/src/client/internal.rs @@ -289,8 +289,8 @@ impl InternalClient { /// This includes the user key, tokens, and encrypted private/signing keys #[cfg(feature = "internal")] pub fn export_session(&self) -> Result { - use bitwarden_encoding::B64; use crate::key_management::{AsymmetricKeyId, SymmetricKeyId}; + use bitwarden_encoding::B64; use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize)] @@ -336,8 +336,7 @@ impl InternalClient { }; // Serialize to JSON and then base64 encode - let json = serde_json::to_string(&session_data) - .map_err(|_| CryptoError::InvalidKey)?; + let json = serde_json::to_string(&session_data).map_err(|_| CryptoError::InvalidKey)?; let encoded = bitwarden_encoding::B64::from(json.as_bytes()); Ok(encoded.to_string()) @@ -347,9 +346,9 @@ impl InternalClient { /// This includes restoring the user key, private key, and setting tokens #[cfg(feature = "internal")] pub fn import_session(&self, session: &str) -> Result<(), CryptoError> { + use crate::key_management::{AsymmetricKeyId, SymmetricKeyId}; use bitwarden_crypto::{AsymmetricCryptoKey, Pkcs8PrivateKeyBytes}; use bitwarden_encoding::B64; - use crate::key_management::{AsymmetricKeyId, SymmetricKeyId}; use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize)] @@ -362,12 +361,11 @@ impl InternalClient { } // Decode from base64 and parse JSON - let decoded = B64::try_from(session.to_string()) - .map_err(|_| CryptoError::InvalidKey)?; - let json_str = String::from_utf8(decoded.as_bytes().to_vec()) - .map_err(|_| CryptoError::InvalidKey)?; - let session_data: SessionData = serde_json::from_str(&json_str) - .map_err(|_| CryptoError::InvalidKey)?; + let decoded = B64::try_from(session.to_string()).map_err(|_| CryptoError::InvalidKey)?; + let json_str = + String::from_utf8(decoded.as_bytes().to_vec()).map_err(|_| CryptoError::InvalidKey)?; + let session_data: SessionData = + serde_json::from_str(&json_str).map_err(|_| CryptoError::InvalidKey)?; // Restore the user key and private key let user_key = SymmetricCryptoKey::try_from(session_data.user_key)?; @@ -379,8 +377,8 @@ impl InternalClient { // Restore private key if present if let Some(private_key_b64) = session_data.private_key { - let private_key_b64_parsed = B64::try_from(private_key_b64) - .map_err(|_| CryptoError::InvalidKey)?; + let private_key_b64_parsed = + B64::try_from(private_key_b64).map_err(|_| CryptoError::InvalidKey)?; let private_key_der = Pkcs8PrivateKeyBytes::from(private_key_b64_parsed.as_bytes()); let private_key = AsymmetricCryptoKey::from_der(&private_key_der)?; ctx.set_asymmetric_key(AsymmetricKeyId::UserPrivateKey, private_key)?; From a57175ed4156a95e741798f65b5dcf0e2d54f379 Mon Sep 17 00:00:00 2001 From: fer <21882+fer@users.noreply.github.com> Date: Mon, 6 Oct 2025 22:25:04 +0200 Subject: [PATCH 06/12] Fix nightly rustfmt import grouping Separate external crate imports from internal crate imports with blank lines to comply with nightly rustfmt group_imports feature used in CI. --- crates/bitwarden-core/src/client/internal.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/bitwarden-core/src/client/internal.rs b/crates/bitwarden-core/src/client/internal.rs index 2595fe9b5..a5163ee7e 100644 --- a/crates/bitwarden-core/src/client/internal.rs +++ b/crates/bitwarden-core/src/client/internal.rs @@ -289,10 +289,11 @@ impl InternalClient { /// This includes the user key, tokens, and encrypted private/signing keys #[cfg(feature = "internal")] pub fn export_session(&self) -> Result { - use crate::key_management::{AsymmetricKeyId, SymmetricKeyId}; use bitwarden_encoding::B64; use serde::{Deserialize, Serialize}; + use crate::key_management::{AsymmetricKeyId, SymmetricKeyId}; + #[derive(Serialize, Deserialize)] struct SessionData { user_key: String, @@ -346,11 +347,12 @@ impl InternalClient { /// This includes restoring the user key, private key, and setting tokens #[cfg(feature = "internal")] pub fn import_session(&self, session: &str) -> Result<(), CryptoError> { - use crate::key_management::{AsymmetricKeyId, SymmetricKeyId}; use bitwarden_crypto::{AsymmetricCryptoKey, Pkcs8PrivateKeyBytes}; use bitwarden_encoding::B64; use serde::{Deserialize, Serialize}; + use crate::key_management::{AsymmetricKeyId, SymmetricKeyId}; + #[derive(Serialize, Deserialize)] struct SessionData { user_key: String, From 398c69d30fc5add83702e0acfcb95d5f2923c930 Mon Sep 17 00:00:00 2001 From: fer <21882+fer@users.noreply.github.com> Date: Tue, 7 Oct 2025 11:47:45 +0200 Subject: [PATCH 07/12] added basic information to bw's doc --- crates/bw/README.md | 47 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/crates/bw/README.md b/crates/bw/README.md index e0c76f14c..475ec3977 100644 --- a/crates/bw/README.md +++ b/crates/bw/README.md @@ -1,3 +1,50 @@ # Bitwarden CLI (testing) A testing CLI for the Bitwarden Password Manager SDK. + +## Authentication + +### Login with API Key + +```bash +# With environment variables +export BW_CLIENTID="user.xxx" +export BW_CLIENTSECRET="xxx" +export BW_PASSWORD="xxx" +bw login api-key + +# Or with interactive prompts +bw login api-key +``` + +The login command returns a session key that can be used for subsequent commands. + +### Using Sessions + +```bash +# Save session to environment variable +export BW_SESSION="" + +# Or pass directly to commands +bw list items --session "" +``` + +## Commands + +### List Items + +```bash +# List all items +bw list items + +# Search items +bw list items --search "github" + +# Filter by folder, collection, or organization +bw list items --folderid "" +bw list items --collectionid "" +bw list items --organizationid "" + +# Show deleted items +bw list items --trash +``` From 9236ae5a0b98453598926aeed9a175588596732d Mon Sep 17 00:00:00 2001 From: fer <21882+fer@users.noreply.github.com> Date: Fri, 17 Oct 2025 11:35:59 +0200 Subject: [PATCH 08/12] rename text_or_env_prompt to resolve_user_input_value --- crates/bitwarden-cli/src/lib.rs | 2 +- crates/bw/src/auth/login.rs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/bitwarden-cli/src/lib.rs b/crates/bitwarden-cli/src/lib.rs index 7dcaa7f70..ec85f71c2 100644 --- a/crates/bitwarden-cli/src/lib.rs +++ b/crates/bitwarden-cli/src/lib.rs @@ -19,7 +19,7 @@ pub fn text_prompt_when_none(prompt: &str, val: Option) -> InquireResult /// Try to get a value from CLI arg, then from environment variables, then prompt /// /// Checks multiple environment variable names in order (e.g., BW_CLIENTID, BW_CLIENT_ID) -pub fn text_or_env_prompt( +pub fn resolve_user_input_value( prompt: &str, cli_val: Option, env_var_names: &[&str], diff --git a/crates/bw/src/auth/login.rs b/crates/bw/src/auth/login.rs index c4aa4c881..095d4e219 100644 --- a/crates/bw/src/auth/login.rs +++ b/crates/bw/src/auth/login.rs @@ -1,4 +1,4 @@ -use bitwarden_cli::{text_or_env_prompt, text_prompt_when_none}; +use bitwarden_cli::{resolve_user_input_value, text_prompt_when_none}; use bitwarden_core::{ Client, auth::login::{ @@ -94,8 +94,8 @@ pub(crate) async fn login_api_key( client_id: Option, client_secret: Option, ) -> Result { - let client_id = text_or_env_prompt("Client ID", client_id, &["BW_CLIENTID", "BW_CLIENT_ID"])?; - let client_secret = text_or_env_prompt( + let client_id = resolve_user_input_value("Client ID", client_id, &["BW_CLIENTID", "BW_CLIENT_ID"])?; + let client_secret = resolve_user_input_value( "Client Secret", client_secret, &["BW_CLIENTSECRET", "BW_CLIENT_SECRET"], From b1ef4e53290d6f01e2ea3e888bc0a0bffc8559c7 Mon Sep 17 00:00:00 2001 From: fer <21882+fer@users.noreply.github.com> Date: Fri, 17 Oct 2025 19:10:56 +0200 Subject: [PATCH 09/12] linter --- crates/bw/src/auth/login.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/bw/src/auth/login.rs b/crates/bw/src/auth/login.rs index 095d4e219..0475a33d6 100644 --- a/crates/bw/src/auth/login.rs +++ b/crates/bw/src/auth/login.rs @@ -94,7 +94,8 @@ pub(crate) async fn login_api_key( client_id: Option, client_secret: Option, ) -> Result { - let client_id = resolve_user_input_value("Client ID", client_id, &["BW_CLIENTID", "BW_CLIENT_ID"])?; + let client_id = + resolve_user_input_value("Client ID", client_id, &["BW_CLIENTID", "BW_CLIENT_ID"])?; let client_secret = resolve_user_input_value( "Client Secret", client_secret, From 48861996c7bb6a3acd6f363da6eaad50d72a72e8 Mon Sep 17 00:00:00 2001 From: fer <21882+fer@users.noreply.github.com> Date: Fri, 17 Oct 2025 19:44:12 +0200 Subject: [PATCH 10/12] refactor: convert list object parameter from String to enum --- crates/bw/src/command.rs | 2 +- crates/bw/src/vault/list.rs | 35 ++++++++++++++++++++--------------- crates/bw/src/vault/mod.rs | 2 +- 3 files changed, 22 insertions(+), 17 deletions(-) diff --git a/crates/bw/src/command.rs b/crates/bw/src/command.rs index 91840d9b0..a08fca2a2 100644 --- a/crates/bw/src/command.rs +++ b/crates/bw/src/command.rs @@ -147,7 +147,7 @@ Notes: #[command(long_about = "List an array of objects from the vault.")] List { /// Object type to list (items, folders, collections, etc.) - object: String, + object: crate::vault::ObjectType, #[arg(long, help = "Perform a search on the listed objects")] search: Option, diff --git a/crates/bw/src/vault/list.rs b/crates/bw/src/vault/list.rs index c3500aa9c..ea5eb18aa 100644 --- a/crates/bw/src/vault/list.rs +++ b/crates/bw/src/vault/list.rs @@ -1,12 +1,24 @@ use bitwarden_core::Client; use bitwarden_vault::{CipherListView, SyncRequest, VaultClientExt}; +use clap::ValueEnum; use color_eyre::eyre::{Result, bail}; use crate::render::CommandOutput; +#[derive(Debug, Clone, Copy, ValueEnum)] +#[value(rename_all = "kebab-case")] +pub enum ObjectType { + Items, + Folders, + Collections, + Organizations, + OrgCollections, + OrgMembers, +} + #[derive(Debug)] pub struct ListOptions { - pub object: String, + pub object: ObjectType, pub search: Option, pub folderid: Option, pub collectionid: Option, @@ -15,30 +27,23 @@ pub struct ListOptions { } pub async fn list(client: &Client, options: ListOptions) -> Result { - match options.object.as_str() { - "items" => list_items(client, options).await, - "folders" => { + match options.object { + ObjectType::Items => list_items(client, options).await, + ObjectType::Folders => { bail!("Listing folders is not yet implemented") } - "collections" => { + ObjectType::Collections => { bail!("Listing collections is not yet implemented") } - "organizations" => { + ObjectType::Organizations => { bail!("Listing organizations is not yet implemented") } - "org-collections" => { + ObjectType::OrgCollections => { bail!("Listing org-collections is not yet implemented") } - "org-members" => { + ObjectType::OrgMembers => { bail!("Listing org-members is not yet implemented") } - _ => { - bail!( - "Invalid object type '{}'. Valid objects are: items, folders, collections, \ - organizations, org-collections, org-members", - options.object - ) - } } } diff --git a/crates/bw/src/vault/mod.rs b/crates/bw/src/vault/mod.rs index fb1d9d008..fc0cfcb31 100644 --- a/crates/bw/src/vault/mod.rs +++ b/crates/bw/src/vault/mod.rs @@ -3,7 +3,7 @@ use clap::Subcommand; use crate::render::{CommandOutput, CommandResult}; pub mod list; -pub use list::{ListOptions, list}; +pub use list::{ListOptions, ObjectType, list}; #[derive(Subcommand, Clone)] pub enum ItemCommands { From aec97b31c1c2d01bb5725c01d4845f12f2bf2549 Mon Sep 17 00:00:00 2001 From: fer <21882+fer@users.noreply.github.com> Date: Fri, 17 Oct 2025 19:59:46 +0200 Subject: [PATCH 11/12] feat: add archive command scaffold for CLI Add unimplemented archive feature to match Node.js CLI: - Add `archive` command with todo!() placeholder - Add `--archived` flag to list command - Update ListOptions struct with archived field Should align with bitwarden/clients#16502 for future implementation. --- crates/bw/src/command.rs | 5 +++++ crates/bw/src/main.rs | 3 +++ crates/bw/src/vault/list.rs | 1 + 3 files changed, 9 insertions(+) diff --git a/crates/bw/src/command.rs b/crates/bw/src/command.rs index a08fca2a2..41fdb7cb6 100644 --- a/crates/bw/src/command.rs +++ b/crates/bw/src/command.rs @@ -163,6 +163,9 @@ Notes: #[arg(long, help = "Filter items that are deleted and in the trash")] trash: bool, + + #[arg(long, help = "Filter items that are archived")] + archived: bool, }, #[command(long_about = "Get an object from the vault.")] Get, @@ -174,6 +177,8 @@ Notes: Delete, #[command(long_about = "Restores an object from the trash.")] Restore, + #[command(long_about = "Archive an object from the vault.")] + Archive, #[command(long_about = "Move an item to an organization.")] Move, diff --git a/crates/bw/src/main.rs b/crates/bw/src/main.rs index c90592125..af5f5301c 100644 --- a/crates/bw/src/main.rs +++ b/crates/bw/src/main.rs @@ -110,6 +110,7 @@ async fn process_commands(command: Commands, session: Option) -> Command collectionid, organizationid, trash, + archived, } => { vault::list( &client.0, @@ -120,6 +121,7 @@ async fn process_commands(command: Commands, session: Option) -> Command collectionid, organizationid, trash, + archived, }, ) .await @@ -129,6 +131,7 @@ async fn process_commands(command: Commands, session: Option) -> Command Commands::Edit => todo!(), Commands::Delete => todo!(), Commands::Restore => todo!(), + Commands::Archive => todo!(), Commands::Move => todo!(), // Admin console commands diff --git a/crates/bw/src/vault/list.rs b/crates/bw/src/vault/list.rs index ea5eb18aa..7290b5ac9 100644 --- a/crates/bw/src/vault/list.rs +++ b/crates/bw/src/vault/list.rs @@ -24,6 +24,7 @@ pub struct ListOptions { pub collectionid: Option, pub organizationid: Option, pub trash: bool, + pub archived: bool, } pub async fn list(client: &Client, options: ListOptions) -> Result { From 3a42f2be25673c7da025919e088f861ce6ec8f92 Mon Sep 17 00:00:00 2001 From: fer <21882+fer@users.noreply.github.com> Date: Fri, 17 Oct 2025 20:31:51 +0200 Subject: [PATCH 12/12] feat: enhance CLI search to match nodejs implementation --- crates/bw/src/vault/list.rs | 40 +++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/crates/bw/src/vault/list.rs b/crates/bw/src/vault/list.rs index 7290b5ac9..f3c73295c 100644 --- a/crates/bw/src/vault/list.rs +++ b/crates/bw/src/vault/list.rs @@ -99,10 +99,46 @@ async fn list_items(client: &Client, options: ListOptions) -> Result= 8 chars) + let id_matches = if search_lower.len() >= 8 { + item.id + .as_ref() + .map(|id| id.to_string().to_lowercase().starts_with(&search_lower)) + .unwrap_or(false) + } else { + false + }; + + // Check if search term matches name + let name_matches = item.name.to_lowercase().contains(&search_lower); + + // Check if search term matches subtitle + let subtitle_matches = item.subtitle.to_lowercase().contains(&search_lower); + + // Check if search term matches any URIs (for login items) + let uri_matches = if let bitwarden_vault::CipherListViewType::Login(ref login) = item.r#type { + login + .uris + .as_ref() + .map(|uris| { + uris.iter().any(|uri| { + uri.uri + .as_ref() + .map(|u| u.to_lowercase().contains(&search_lower)) + .unwrap_or(false) + }) + }) + .unwrap_or(false) + } else { + false + }; + + // Item matches if any of the fields match + if !(id_matches || name_matches || subtitle_matches || uri_matches) { return false; } }