Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ rpass git status
- **Works everywhere** — Linux, macOS, Windows (no Bash dependency)
- **password-store compatible** — reads and writes existing `.gpg` entries and `.gpg-id` files
- **JSON output** — structured responses for Raycast, Vicinae, and custom integrations
- **Import** — migrate from other password managers ([docs](docs/import.md))
- **TOTP support** — generate one-time codes from `otpauth://` lines
- **Git integration** — explicit `rpass git <args>` commands; write commands auto-commit
- **Shell completions** — Bash, Zsh, Fish, PowerShell
Expand Down Expand Up @@ -56,6 +57,7 @@ rpass insert example/login # insert a password
rpass edit example/login # edit an entry
rpass rm example/login # remove an entry
rpass mv example/login archive/login # move/rename an entry
rpass import --bitwarden ~/export.json # import from Bitwarden
rpass otp example/login # generate a TOTP code
rpass git status # run git inside the store
rpass doctor # check your local setup
Expand Down
17 changes: 17 additions & 0 deletions docs/import.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Import

Import passwords from other password managers into your rpass store.

rpass import --bitwarden <file>

## Bitwarden

1. In Bitwarden, go to **Tools → Export vault** and select **File format: JSON** (unencrypted).
2. Run: `rpass import --bitwarden ~/bitwarden_export.json`

| Flag | Description |
|---|---|
| `--force` | Overwrite existing entries |
| `--json` | JSON output |

Supports individual and organization vaults. Folders and collections become path prefixes. All item types are handled: logins, secure notes, cards, and identities.
85 changes: 82 additions & 3 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ use crate::password_generator::{
default_passphrase_words, default_password_length, generate_passphrase, generate_password,
max_passphrase_words, max_password_length,
};
use crate::password_store::importer::bitwarden::BitwardenImporter;
use crate::password_store::{
DecryptedEntry, DoctorReport, EditEntry, GitCommand, GpgCommand, InitStore, InitStoreResult,
InsertEntry, ListEntries, MoveEntry, OtpCode, PasswordStore, Recipients, RecipientsResult,
RemoveEntry, SearchEntries, ShowEntry, StoreDirectory,
DecryptedEntry, DoctorReport, EditEntry, GitCommand, GpgCommand, ImportEntries, ImportResult,
InitStore, InitStoreResult, InsertEntry, ListEntries, MoveEntry, OtpCode, PasswordStore,
Recipients, RecipientsResult, RemoveEntry, SearchEntries, ShowEntry, StoreDirectory,
};
use tree_output::EntryTree;

Expand Down Expand Up @@ -100,6 +101,9 @@ enum Command {

#[command(name = "complete-entries", hide = true)]
CompleteEntries(CompleteEntriesCommand),

#[command(about = "Import entries from other password managers")]
Import(ImportCommand),
}

#[derive(Debug, Parser)]
Expand Down Expand Up @@ -359,6 +363,21 @@ struct CompleteEntriesCommand {
prefix: String,
}

#[derive(Debug, Parser)]
struct ImportCommand {
#[arg(help = "Import from a Bitwarden JSON export", long = "bitwarden")]
bitwarden: bool,

#[arg(help = "Path to the import file")]
file: PathBuf,

#[arg(short = 'f', long, help = "Overwrite existing entries")]
force: bool,

#[arg(long, help = "Output in JSON format")]
json: bool,
}

pub fn run() -> Result<(), CliError> {
let cli = Cli::parse();

Expand All @@ -385,6 +404,7 @@ pub fn run() -> Result<(), CliError> {
Some(Command::Otp(command)) => generate_otp(command, store_directory),
Some(Command::Search(command)) => search_entries(command, store_directory),
Some(Command::Doctor(command)) => run_doctor(command, store_directory),
Some(Command::Import(command)) => import_entries(command, store_directory),
Some(Command::CompleteEntries(command)) => {
completion::complete_entries(&command.prefix, store_dir);
Ok(())
Expand Down Expand Up @@ -440,6 +460,7 @@ impl Command {
Self::Otp(command) => command.json,
Self::Search(command) => command.json,
Self::Doctor(command) => command.json,
Self::Import(command) => command.json,
Self::Completions(_) | Self::CompleteEntries(_) => false,
}
}
Expand Down Expand Up @@ -662,6 +683,60 @@ fn print_json_insert(entry_name: &str) -> Result<(), CliError> {
Ok(())
}

fn import_entries(command: ImportCommand, store_directory: StoreDirectory) -> Result<(), CliError> {
let store = PasswordStore::open(store_directory)?;
let gpg = GpgCommand::from_environment();
let data = std::fs::read_to_string(&command.file).map_err(|e| {
CliError::ImportFailed(format!("cannot read '{}': {}", command.file.display(), e))
})?;

let result =
ImportEntries::new(&store, &gpg).execute(&BitwardenImporter, &data, command.force)?;

auto_commit(
&store,
&format!("Imported {} entries from Bitwarden", result.imported),
)?;

if command.json {
print_json_import(&result, "Bitwarden")?;
} else {
print_text_import(&result, "Bitwarden");
}

Ok(())
}

fn print_text_import(result: &ImportResult, source: &str) {
println!(
"Imported {}/{} entries from {}",
result.imported,
result.imported + result.skipped,
source
);
for error in &result.errors {
eprintln!(" {}", error);
}
}

fn print_json_import(result: &ImportResult, source: &str) -> Result<(), CliError> {
#[derive(Serialize)]
struct ImportJson<'a> {
source: &'a str,
imported: usize,
skipped: usize,
errors: &'a [String],
}
let json = serde_json::to_string_pretty(&ImportJson {
source,
imported: result.imported,
skipped: result.skipped,
errors: &result.errors,
})?;
println!("{json}");
Ok(())
}

fn auto_commit(store: &PasswordStore, message: &str) -> Result<(), CliError> {
GitCommand::from_environment().auto_commit(store, message)?;
Ok(())
Expand Down Expand Up @@ -1159,6 +1234,9 @@ pub enum CliError {
#[error("doctor checks failed")]
DoctorFailed,

#[error("import failed: {0}")]
ImportFailed(String),

#[error("error already reported")]
Reported,

Expand Down Expand Up @@ -1188,6 +1266,7 @@ impl CliError {
Self::RemoveConfirmationRequired => "remove_confirmation_required",
Self::RemoveAborted => "remove_aborted",
Self::DoctorFailed => "doctor_checks_failed",
Self::ImportFailed(_) => "import_failed",
Self::Reported => "reported",
Self::NoEntryPoint => "no_entry_or_subcommand_provided",
}
Expand Down
Loading
Loading