docs: add comprehensive doc comments to all Rust source files
Document every public function, struct, field, constant, and non-trivial private function across idfoto-core and idfoto-cli. Module-level docs explain each module's role in the architecture. Comments explain the "why" (crypto choices, algorithm design, data model rationale) not just the "what". Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,42 @@
|
||||
//! idfoto CLI -- the platform layer for the idfoto password manager.
|
||||
//!
|
||||
//! This binary provides the filesystem, git, and terminal I/O that
|
||||
//! [`idfoto_core`] intentionally excludes. It is the "glue" between the
|
||||
//! platform-agnostic core library and the user's local environment.
|
||||
//!
|
||||
//! ## Vault layout on disk
|
||||
//!
|
||||
//! ```text
|
||||
//! <vault_dir>/
|
||||
//! .idfoto/
|
||||
//! salt # 32-byte random salt for Argon2id KDF
|
||||
//! params.json # KDF tuning parameters (m, t, p)
|
||||
//! devices.json # registered device public keys
|
||||
//! entries/
|
||||
//! <id>.enc # individual encrypted entries
|
||||
//! manifest.enc # encrypted entry index (name, url, username per entry)
|
||||
//! .gitignore # excludes reference.jpg from version control
|
||||
//! reference.jpg # the reference image with embedded secret (gitignored)
|
||||
//! ```
|
||||
//!
|
||||
//! ## Unlock flow
|
||||
//!
|
||||
//! Every command that accesses vault data follows this sequence:
|
||||
//!
|
||||
//! 1. Locate the reference image (via `IDFOTO_IMAGE` env var or interactive prompt).
|
||||
//! 2. Prompt for the passphrase (read from stderr, not echoed).
|
||||
//! 3. Extract the 32-byte image secret from the reference JPEG via DCT steganography.
|
||||
//! 4. Read the vault salt and KDF params from `.idfoto/`.
|
||||
//! 5. Derive the master key: `Argon2id(passphrase || image_secret, salt, params)`.
|
||||
//! 6. Use the master key to decrypt the manifest and/or individual entries.
|
||||
//!
|
||||
//! ## Git integration
|
||||
//!
|
||||
//! The CLI shells out to the `git` binary for all version control operations.
|
||||
//! This avoids pulling in libgit2 or gitoxide as dependencies, keeping the
|
||||
//! binary small and the build simple. Every mutation (add, edit, rm, device add/revoke)
|
||||
//! creates a git commit, preserving an audit log of all vault changes.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use idfoto_core::{
|
||||
@@ -14,6 +53,7 @@ use std::process::Command;
|
||||
|
||||
// ─── CLI structure ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Top-level CLI argument parser.
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "idfoto",
|
||||
@@ -25,70 +65,105 @@ struct Cli {
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
/// All available CLI subcommands.
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Initialize a new idfoto vault
|
||||
/// Initialize a new idfoto vault in the current directory.
|
||||
/// Creates the directory structure, generates a random image secret,
|
||||
/// embeds it in the carrier image, and sets up git.
|
||||
Init {
|
||||
/// Path to the carrier JPEG image to embed the secret into.
|
||||
#[arg(long)]
|
||||
image: PathBuf,
|
||||
/// Output path for the reference image (with embedded secret).
|
||||
#[arg(long, default_value = "reference.jpg")]
|
||||
output: PathBuf,
|
||||
},
|
||||
/// Add a new password entry
|
||||
/// Add a new password entry to the vault.
|
||||
/// Prompts interactively for name, URL, username, password, notes, and TOTP.
|
||||
Add,
|
||||
/// Get a password entry by name
|
||||
/// Get a password entry by name (fuzzy search).
|
||||
/// Decrypts and displays the full entry, and copies the password to clipboard
|
||||
/// with a 30-second auto-clear.
|
||||
Get { name: String },
|
||||
/// List all entries
|
||||
/// List all entries in the vault (names, URLs, usernames only -- no passwords).
|
||||
List,
|
||||
/// Edit an existing entry
|
||||
/// Edit an existing entry by name (fuzzy search).
|
||||
/// Shows current values and lets you selectively update fields.
|
||||
Edit { name: String },
|
||||
/// Remove an entry
|
||||
/// Remove an entry from the vault by name (fuzzy search).
|
||||
/// Prompts for confirmation before deleting.
|
||||
Rm { name: String },
|
||||
/// Sync vault with git remote
|
||||
/// Sync the vault with the git remote (pull --rebase, then push).
|
||||
Sync,
|
||||
/// Generate a random password
|
||||
/// Generate a random password and print it to stdout.
|
||||
Generate {
|
||||
/// Length of the generated password in characters.
|
||||
#[arg(short, long, default_value = "20")]
|
||||
length: usize,
|
||||
},
|
||||
/// Manage devices
|
||||
/// Manage device keys (add, list, revoke).
|
||||
/// Device ed25519 keys are independent of the vault KDF -- revoking a device
|
||||
/// does not require changing the passphrase or reference image.
|
||||
Device {
|
||||
#[command(subcommand)]
|
||||
action: DeviceCommands,
|
||||
},
|
||||
}
|
||||
|
||||
/// Subcommands for device key management.
|
||||
#[derive(Subcommand)]
|
||||
enum DeviceCommands {
|
||||
/// Add a new device
|
||||
/// Register a new device by generating an ed25519 keypair.
|
||||
/// The private key is saved to the user's config directory;
|
||||
/// the public key is added to the vault's devices.json.
|
||||
Add {
|
||||
/// Human-readable name for this device (e.g., "macbook", "phone").
|
||||
#[arg(long)]
|
||||
name: String,
|
||||
},
|
||||
/// List registered devices
|
||||
/// List all registered devices and their public keys.
|
||||
List,
|
||||
/// Revoke a device
|
||||
/// Revoke a device by removing its public key from devices.json.
|
||||
/// This does NOT rotate the vault key -- the device can no longer
|
||||
/// authenticate, but the vault encryption is unchanged.
|
||||
Revoke { name: String },
|
||||
}
|
||||
|
||||
// ─── Device entry ───────────────────────────────────────────────────────────
|
||||
|
||||
/// A registered device, stored in `.idfoto/devices.json`.
|
||||
///
|
||||
/// Each device has an ed25519 keypair. The private key lives on the device
|
||||
/// itself (in the user's config directory); only the public key is stored
|
||||
/// in the vault. This separation means revoking a device is a metadata-only
|
||||
/// operation that does not affect the vault's encryption key.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct DeviceEntry {
|
||||
/// Human-readable device name (e.g., "macbook-pro", "pixel-7").
|
||||
name: String,
|
||||
/// Hex-encoded ed25519 public key (64 hex chars = 32 bytes).
|
||||
public_key: String, // hex-encoded
|
||||
}
|
||||
|
||||
// ─── Helper functions ───────────────────────────────────────────────────────
|
||||
|
||||
/// Returns the vault root directory (the current working directory).
|
||||
/// The vault is always rooted at the directory where `idfoto` is invoked.
|
||||
fn vault_dir() -> PathBuf {
|
||||
std::env::current_dir().expect("failed to get current directory")
|
||||
}
|
||||
|
||||
/// Returns the path to the `.idfoto/` configuration directory within the vault.
|
||||
fn idfoto_dir() -> PathBuf {
|
||||
vault_dir().join(".idfoto")
|
||||
}
|
||||
|
||||
/// Read the 32-byte vault salt from `.idfoto/salt`.
|
||||
///
|
||||
/// The salt is generated once during `init` and is unique per vault. It is
|
||||
/// not secret (stored in plaintext) -- its purpose is to prevent precomputed
|
||||
/// rainbow table attacks against the Argon2id KDF.
|
||||
fn read_salt() -> Result<[u8; 32]> {
|
||||
let data = fs::read(idfoto_dir().join("salt")).context("failed to read salt")?;
|
||||
let mut salt = [0u8; 32];
|
||||
@@ -99,6 +174,7 @@ fn read_salt() -> Result<[u8; 32]> {
|
||||
Ok(salt)
|
||||
}
|
||||
|
||||
/// Read the KDF parameters from `.idfoto/params.json`.
|
||||
fn read_params() -> Result<KdfParams> {
|
||||
let data = fs::read_to_string(idfoto_dir().join("params.json"))
|
||||
.context("failed to read params.json")?;
|
||||
@@ -106,6 +182,10 @@ fn read_params() -> Result<KdfParams> {
|
||||
Ok(params)
|
||||
}
|
||||
|
||||
/// Locate the reference image path.
|
||||
///
|
||||
/// First checks the `IDFOTO_IMAGE` environment variable (useful for scripting
|
||||
/// and testing). If not set, prompts the user interactively.
|
||||
fn get_image_path() -> Result<PathBuf> {
|
||||
if let Ok(path) = std::env::var("IDFOTO_IMAGE") {
|
||||
return Ok(PathBuf::from(path));
|
||||
@@ -114,6 +194,13 @@ fn get_image_path() -> Result<PathBuf> {
|
||||
Ok(PathBuf::from(path))
|
||||
}
|
||||
|
||||
/// Perform the two-factor unlock sequence and return the derived master key.
|
||||
///
|
||||
/// This is the core authentication flow used by every vault-access command:
|
||||
/// 1. Prompt for the passphrase (via rpassword, not echoed to terminal).
|
||||
/// 2. Read and decode the reference JPEG, extracting the steganographic secret.
|
||||
/// 3. Load the vault salt and KDF params.
|
||||
/// 4. Derive the master key via Argon2id(passphrase || image_secret, salt).
|
||||
fn unlock(image_path: &PathBuf) -> Result<[u8; 32]> {
|
||||
let passphrase = rpassword::prompt_password_stderr("Passphrase: ").context("failed to read passphrase")?;
|
||||
|
||||
@@ -130,18 +217,25 @@ fn unlock(image_path: &PathBuf) -> Result<[u8; 32]> {
|
||||
Ok(master_key)
|
||||
}
|
||||
|
||||
/// Decrypt and return the vault manifest.
|
||||
fn read_manifest(key: &[u8; 32]) -> Result<Manifest> {
|
||||
let data = fs::read(vault_dir().join("manifest.enc")).context("failed to read manifest.enc")?;
|
||||
let manifest = decrypt_manifest(key, &data).context("failed to decrypt manifest")?;
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
/// Encrypt and write the vault manifest to disk.
|
||||
fn write_manifest(key: &[u8; 32], manifest: &Manifest) -> Result<()> {
|
||||
let data = encrypt_manifest(key, manifest).context("failed to encrypt manifest")?;
|
||||
fs::write(vault_dir().join("manifest.enc"), data).context("failed to write manifest.enc")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stage all changes and create a git commit with the given message.
|
||||
///
|
||||
/// Every vault mutation is committed to preserve a full audit log in git history.
|
||||
/// The CLI shells out to the `git` binary rather than using a Rust git library
|
||||
/// to keep dependencies minimal.
|
||||
fn git_commit(message: &str) -> Result<()> {
|
||||
let status = Command::new("git")
|
||||
.args(["add", "-A"])
|
||||
@@ -162,6 +256,10 @@ fn git_commit(message: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return the current time as a Unix timestamp string.
|
||||
///
|
||||
/// Uses seconds since epoch rather than a formatted ISO 8601 string to avoid
|
||||
/// pulling in chrono or time crate dependencies.
|
||||
fn now_iso8601() -> String {
|
||||
let duration = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -169,6 +267,7 @@ fn now_iso8601() -> String {
|
||||
format!("{}", duration.as_secs())
|
||||
}
|
||||
|
||||
/// Prompt the user for input via stderr (so stdout remains clean for piping).
|
||||
fn prompt(message: &str) -> Result<String> {
|
||||
eprint!("{}: ", message);
|
||||
io::stderr().flush()?;
|
||||
@@ -177,6 +276,7 @@ fn prompt(message: &str) -> Result<String> {
|
||||
Ok(line.trim().to_string())
|
||||
}
|
||||
|
||||
/// Prompt for an optional field. Returns `None` if the user enters an empty string.
|
||||
fn prompt_optional(message: &str) -> Result<Option<String>> {
|
||||
let value = prompt(message)?;
|
||||
if value.is_empty() {
|
||||
@@ -186,6 +286,8 @@ fn prompt_optional(message: &str) -> Result<Option<String>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Prompt for a field with a default value shown in brackets.
|
||||
/// If the user presses Enter without typing, the current value is kept.
|
||||
fn prompt_with_default(field: &str, current: &str) -> Result<String> {
|
||||
eprint!("{} [{}]: ", field, current);
|
||||
io::stderr().flush()?;
|
||||
@@ -199,6 +301,10 @@ fn prompt_with_default(field: &str, current: &str) -> Result<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a random password of the given length using a mixed character set.
|
||||
///
|
||||
/// The charset includes lowercase, uppercase, digits, and common symbols.
|
||||
/// Each character is selected uniformly at random via the OS CSPRNG.
|
||||
fn generate_password(length: usize) -> String {
|
||||
const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+";
|
||||
let mut rng = OsRng;
|
||||
@@ -212,6 +318,19 @@ fn generate_password(length: usize) -> String {
|
||||
|
||||
// ─── Command implementations ────────────────────────────────────────────────
|
||||
|
||||
/// Initialize a new idfoto vault in the current directory.
|
||||
///
|
||||
/// Full sequence:
|
||||
/// 1. Read the carrier JPEG provided by the user.
|
||||
/// 2. Generate a random 32-byte image secret.
|
||||
/// 3. Embed the secret into the carrier via DCT steganography.
|
||||
/// 4. Save the resulting reference JPEG (this is the user's second factor).
|
||||
/// 5. Prompt for a passphrase (minimum 8 characters, with confirmation).
|
||||
/// 6. Generate a random 32-byte salt.
|
||||
/// 7. Derive the master key from passphrase + image_secret + salt.
|
||||
/// 8. Create the vault directory structure (.idfoto/, entries/).
|
||||
/// 9. Write salt, KDF params, empty devices list, and encrypted empty manifest.
|
||||
/// 10. Initialize git and create the first commit.
|
||||
fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
|
||||
// 1. Read carrier JPEG
|
||||
let carrier = fs::read(&image).context("failed to read carrier image")?;
|
||||
@@ -274,7 +393,8 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
|
||||
fs::write(vault_dir().join("manifest.enc"), manifest_enc)
|
||||
.context("failed to write manifest.enc")?;
|
||||
|
||||
// 11. Create .gitignore
|
||||
// 11. Create .gitignore (exclude reference image from version control --
|
||||
// it contains the steganographic secret and must be kept offline)
|
||||
fs::write(vault_dir().join(".gitignore"), "reference.jpg\n")
|
||||
.context("failed to write .gitignore")?;
|
||||
|
||||
@@ -292,11 +412,16 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate a random password and print it to stdout.
|
||||
fn cmd_generate(length: usize) -> Result<()> {
|
||||
println!("{}", generate_password(length));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add a new entry to the vault.
|
||||
///
|
||||
/// Prompts for all fields, encrypts the entry, writes it to `entries/<id>.enc`,
|
||||
/// updates the manifest, and commits the change to git.
|
||||
fn cmd_add() -> Result<()> {
|
||||
let image_path = get_image_path()?;
|
||||
let master_key = unlock(&image_path)?;
|
||||
@@ -362,6 +487,10 @@ fn cmd_add() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Search the manifest for entries matching a query and let the user select one.
|
||||
///
|
||||
/// If exactly one entry matches, it is returned immediately. If multiple match,
|
||||
/// the user is shown a numbered list and prompted to choose.
|
||||
fn search_and_select(manifest: &Manifest, query: &str) -> Result<(String, ManifestEntry)> {
|
||||
let results = manifest.search(query);
|
||||
if results.is_empty() {
|
||||
@@ -394,6 +523,11 @@ fn search_and_select(manifest: &Manifest, query: &str) -> Result<(String, Manife
|
||||
Ok((id.clone(), entry.clone()))
|
||||
}
|
||||
|
||||
/// Retrieve and display a vault entry, and copy its password to the clipboard.
|
||||
///
|
||||
/// The password is auto-cleared from the clipboard after 30 seconds to limit
|
||||
/// exposure. The clipboard clear is best-effort (a background thread checks
|
||||
/// whether the clipboard still contains the password before clearing).
|
||||
fn cmd_get(query: String) -> Result<()> {
|
||||
let image_path = get_image_path()?;
|
||||
let master_key = unlock(&image_path)?;
|
||||
@@ -422,7 +556,10 @@ fn cmd_get(query: String) -> Result<()> {
|
||||
println!("TOTP: {}", totp);
|
||||
}
|
||||
|
||||
// Copy password to clipboard with 30s TTL
|
||||
// Copy password to clipboard with 30s TTL.
|
||||
// Uses arboard for cross-platform clipboard access.
|
||||
// The clear is done in a background thread: after 30 seconds, if the
|
||||
// clipboard still contains this password, it is replaced with an empty string.
|
||||
match arboard::Clipboard::new() {
|
||||
Ok(mut clipboard) => {
|
||||
if clipboard.set_text(&entry.password).is_ok() {
|
||||
@@ -448,6 +585,10 @@ fn cmd_get(query: String) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all vault entries in alphabetical order.
|
||||
///
|
||||
/// Only shows non-sensitive metadata (name, URL, username) from the manifest.
|
||||
/// Individual entry files are not decrypted.
|
||||
fn cmd_list() -> Result<()> {
|
||||
let image_path = get_image_path()?;
|
||||
let master_key = unlock(&image_path)?;
|
||||
@@ -477,6 +618,8 @@ fn cmd_list() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Edit an existing entry by searching for it, showing current values, and
|
||||
/// prompting for new values. Unchanged fields keep their current value.
|
||||
fn cmd_edit(query: String) -> Result<()> {
|
||||
let image_path = get_image_path()?;
|
||||
let master_key = unlock(&image_path)?;
|
||||
@@ -546,6 +689,10 @@ fn cmd_edit(query: String) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove an entry from the vault after confirmation.
|
||||
///
|
||||
/// Deletes the encrypted entry file, removes the entry from the manifest,
|
||||
/// and commits the change to git.
|
||||
fn cmd_rm(query: String) -> Result<()> {
|
||||
let image_path = get_image_path()?;
|
||||
let master_key = unlock(&image_path)?;
|
||||
@@ -576,6 +723,11 @@ fn cmd_rm(query: String) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sync the vault with the git remote.
|
||||
///
|
||||
/// Performs `git pull --rebase` followed by `git push`. Rebase is used instead
|
||||
/// of merge to keep the commit history linear, which is important for the
|
||||
/// audit log use case.
|
||||
fn cmd_sync() -> Result<()> {
|
||||
eprintln!("Pulling...");
|
||||
let status = Command::new("git")
|
||||
@@ -601,6 +753,7 @@ fn cmd_sync() -> Result<()> {
|
||||
|
||||
// ─── Device management ──────────────────────────────────────────────────────
|
||||
|
||||
/// Read the device registry from `.idfoto/devices.json`.
|
||||
fn read_devices() -> Result<Vec<DeviceEntry>> {
|
||||
let path = idfoto_dir().join("devices.json");
|
||||
let data = fs::read_to_string(&path).context("failed to read devices.json")?;
|
||||
@@ -608,30 +761,39 @@ fn read_devices() -> Result<Vec<DeviceEntry>> {
|
||||
Ok(devices)
|
||||
}
|
||||
|
||||
/// Write the device registry to `.idfoto/devices.json`.
|
||||
fn write_devices(devices: &[DeviceEntry]) -> Result<()> {
|
||||
let data = serde_json::to_string_pretty(devices)?;
|
||||
fs::write(idfoto_dir().join("devices.json"), data).context("failed to write devices.json")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Register a new device by generating an ed25519 keypair.
|
||||
///
|
||||
/// The private key is saved to `~/.config/idfoto/<name>.key` with
|
||||
/// restrictive permissions (0600 on Unix). The public key is added to
|
||||
/// the vault's devices.json and committed to git.
|
||||
///
|
||||
/// Device keys are independent of the vault encryption key -- revoking a
|
||||
/// device does not require rotating the passphrase or reference image.
|
||||
fn cmd_device_add(name: String) -> Result<()> {
|
||||
use ed25519_dalek::SigningKey;
|
||||
|
||||
let mut devices = read_devices()?;
|
||||
|
||||
// Check for duplicate
|
||||
// Check for duplicate device names
|
||||
if devices.iter().any(|d| d.name == name) {
|
||||
bail!("device '{}' already exists", name);
|
||||
}
|
||||
|
||||
// Generate ed25519 keypair
|
||||
// Generate ed25519 keypair using the OS CSPRNG
|
||||
let signing_key = SigningKey::generate(&mut OsRng);
|
||||
let verifying_key = signing_key.verifying_key();
|
||||
|
||||
let private_key_hex = hex::encode(signing_key.to_bytes());
|
||||
let public_key_hex = hex::encode(verifying_key.to_bytes());
|
||||
|
||||
// Save private key
|
||||
// Save private key to the user's config directory (NOT in the vault)
|
||||
let config_dir = dirs::config_dir()
|
||||
.context("failed to find config directory")?
|
||||
.join("idfoto");
|
||||
@@ -646,7 +808,7 @@ fn cmd_device_add(name: String) -> Result<()> {
|
||||
fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
|
||||
// Add to devices.json
|
||||
// Add public key to the vault's device registry
|
||||
devices.push(DeviceEntry {
|
||||
name: name.clone(),
|
||||
public_key: public_key_hex,
|
||||
@@ -660,6 +822,7 @@ fn cmd_device_add(name: String) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all registered devices with their public keys.
|
||||
fn cmd_device_list() -> Result<()> {
|
||||
let devices = read_devices()?;
|
||||
|
||||
@@ -677,6 +840,13 @@ fn cmd_device_list() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Revoke a device by removing it from the device registry.
|
||||
///
|
||||
/// This is a metadata-only operation: the device's public key is removed from
|
||||
/// devices.json, but the vault encryption key is NOT rotated. The revoked
|
||||
/// device can no longer authenticate via its ed25519 key, but if it had
|
||||
/// previously derived the master key (via passphrase + image), that key
|
||||
/// remains valid until the user changes their passphrase or reference image.
|
||||
fn cmd_device_revoke(name: String) -> Result<()> {
|
||||
let mut devices = read_devices()?;
|
||||
let initial_len = devices.len();
|
||||
@@ -695,6 +865,7 @@ fn cmd_device_revoke(name: String) -> Result<()> {
|
||||
|
||||
// ─── Main ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Entry point: parse CLI arguments and dispatch to the appropriate command handler.
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user