From 5f3d51b3ae43801fdd95d7066adb7c0c83b98d7d Mon Sep 17 00:00:00 2001 From: Sergei Tsvetkov Date: Sun, 1 Mar 2026 00:09:48 +0100 Subject: [PATCH 1/4] Fix clippy warnings, add tests, and improve robustness - Collapse nested if/if-let chains using Rust 2024 let chains (fixes 15+ clippy errors) - Run cargo fmt across all source files - Add warning output when shared field sync fails during profile switch - Preserve symlinks in copy_dir_recursive during backup/restore - Add 12 integration tests using assert_cmd + predicates - Update CLAUDE.md: add update.rs, fix stale keyring reference --- CLAUDE.md | 7 ++- Cargo.lock | 147 ++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 4 ++ src/backup.rs | 35 ++++++------ src/config.rs | 16 +++--- src/keychain.rs | 28 +++++---- src/main.rs | 114 ++++++++++++++++++------------------- src/profile.rs | 83 +++++++++++++++------------ src/update.rs | 10 +++- tests/cli.rs | 123 ++++++++++++++++++++++++++++++++++++++++ 10 files changed, 432 insertions(+), 135 deletions(-) create mode 100644 tests/cli.rs diff --git a/CLAUDE.md b/CLAUDE.md index 4308dd3..d130ce2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,10 +10,11 @@ claudini/ src/ main.rs # CLI entry point (clap derive), subcommand enums, output formatting config.rs # Path helpers, Config struct, directory layout - keychain.rs # macOS Keychain read/write/delete via keyring crate + keychain.rs # macOS Keychain read/write/delete via security CLI profile.rs # Profile operations: init, add, add --login, use, list, remove, rename, current - backup.rs # Backup operations: create, restore, list + backup.rs # Backup operations: create, restore, delete, list sync.rs # Shared field sync between profiles + update.rs # Version update checking via GitHub releases API ``` ## CLI @@ -78,7 +79,7 @@ cargo run -- - `serde` + `serde_json` — JSON handling - `dirs` — home directory resolution - `anyhow` — error handling -- `keyring` (apple-native) — macOS Keychain access +- macOS `security` CLI — Keychain access (via `std::process::Command`) - `console` — terminal colors/styling - `indicatif` — progress spinners - `comfy-table` — formatted table output diff --git a/Cargo.lock b/Cargo.lock index 1cd3da7..ab2d2fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "anstream" version = "0.6.21" @@ -58,12 +67,44 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "assert_cmd" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c5bcfa8749ac45dd12cb11055aeeb6b27a3895560d60d71e3c23bf979e60514" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "bitflags" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -121,11 +162,13 @@ name = "claudini" version = "0.3.2" dependencies = [ "anyhow", + "assert_cmd", "clap", "comfy-table", "console", "dirs", "indicatif", + "predicates", "serde", "serde_json", ] @@ -183,6 +226,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "dirs" version = "6.0.0" @@ -229,6 +278,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -323,6 +381,21 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "number_prefix" version = "0.4.0" @@ -376,6 +449,36 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -414,6 +517,35 @@ dependencies = [ "thiserror", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "rustix" version = "1.1.4" @@ -505,6 +637,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "thiserror" version = "2.0.18" @@ -549,6 +687,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/Cargo.toml b/Cargo.toml index 087b214..fdcb7e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,3 +16,7 @@ dirs = "6" indicatif = "0.17" serde = { version = "1", features = ["derive"] } serde_json = "1" + +[dev-dependencies] +assert_cmd = "2" +predicates = "3" diff --git a/src/backup.rs b/src/backup.rs index c5ba467..f8b980d 100644 --- a/src/backup.rs +++ b/src/backup.rs @@ -1,6 +1,6 @@ use std::path::Path; -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result, bail}; use crate::config::{backup_dir, claude_json_path, list_backups as config_list_backups}; use crate::keychain; @@ -16,8 +16,7 @@ pub fn create(claudini_dir: &Path, claude_home: &Path, name: &str) -> Result<()> // Copy claude.json (resolving symlink) let cj = claude_json_path(claude_home); if cj.exists() { - std::fs::copy(&cj, bdir.join("claude.json")) - .context("Failed to copy claude.json")?; + std::fs::copy(&cj, bdir.join("claude.json")).context("Failed to copy claude.json")?; } else { bail!("~/.claude.json not found — nothing to back up"); } @@ -106,13 +105,12 @@ pub fn migrate_all_backup_credentials(claudini_dir: &Path) -> usize { let mut count = 0; for name in &backups { let cred_file = backup_dir(claudini_dir, name).join("credentials"); - if cred_file.is_file() { - if let Ok(cred) = std::fs::read_to_string(&cred_file) { - if keychain::write_backup(name, &cred).is_ok() { - let _ = std::fs::remove_file(&cred_file); - count += 1; - } - } + if cred_file.is_file() + && let Ok(cred) = std::fs::read_to_string(&cred_file) + && keychain::write_backup(name, &cred).is_ok() + { + let _ = std::fs::remove_file(&cred_file); + count += 1; } } count @@ -120,12 +118,11 @@ pub fn migrate_all_backup_credentials(claudini_dir: &Path) -> usize { fn migrate_backup_credential_if_needed(claudini_dir: &Path, name: &str) { let cred_file = backup_dir(claudini_dir, name).join("credentials"); - if cred_file.is_file() { - if let Ok(cred) = std::fs::read_to_string(&cred_file) { - if keychain::write_backup(name, &cred).is_ok() { - let _ = std::fs::remove_file(&cred_file); - } - } + if cred_file.is_file() + && let Ok(cred) = std::fs::read_to_string(&cred_file) + && keychain::write_backup(name, &cred).is_ok() + { + let _ = std::fs::remove_file(&cred_file); } } @@ -135,7 +132,11 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> { let entry = entry?; let src_path = entry.path(); let dst_path = dst.join(entry.file_name()); - if src_path.is_dir() { + let ft = entry.file_type()?; + if ft.is_symlink() { + let target = std::fs::read_link(&src_path)?; + std::os::unix::fs::symlink(&target, &dst_path)?; + } else if ft.is_dir() { copy_dir_recursive(&src_path, &dst_path)?; } else { std::fs::copy(&src_path, &dst_path)?; diff --git a/src/config.rs b/src/config.rs index 8b83f17..0cadc42 100644 --- a/src/config.rs +++ b/src/config.rs @@ -89,10 +89,10 @@ pub fn list_profiles(claudini_dir: &Path) -> Result> { let mut names = Vec::new(); for entry in std::fs::read_dir(&dir)? { let entry = entry?; - if entry.file_type()?.is_dir() { - if let Some(name) = entry.file_name().to_str() { - names.push(name.to_string()); - } + if entry.file_type()?.is_dir() + && let Some(name) = entry.file_name().to_str() + { + names.push(name.to_string()); } } names.sort(); @@ -108,10 +108,10 @@ pub fn list_backups(claudini_dir: &Path) -> Result> { let mut names = Vec::new(); for entry in std::fs::read_dir(&dir)? { let entry = entry?; - if entry.file_type()?.is_dir() { - if let Some(name) = entry.file_name().to_str() { - names.push(name.to_string()); - } + if entry.file_type()?.is_dir() + && let Some(name) = entry.file_name().to_str() + { + names.push(name.to_string()); } } names.sort(); diff --git a/src/keychain.rs b/src/keychain.rs index 8c63ed0..0beaf00 100644 --- a/src/keychain.rs +++ b/src/keychain.rs @@ -1,4 +1,4 @@ -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result, bail}; const SERVICE_NAME: &str = "Claude Code-credentials"; @@ -33,13 +33,18 @@ fn security_write(service: &str, data: &str, trusted_apps: &[String]) -> Result< let mut args = vec![ "add-generic-password".to_string(), - "-s".to_string(), service.to_string(), - "-a".to_string(), user, - "-w".to_string(), data.to_string(), + "-s".to_string(), + service.to_string(), + "-a".to_string(), + user, + "-w".to_string(), + data.to_string(), ]; if trusted_apps.is_empty() { - bail!("No trusted applications resolved — refusing to create a keychain entry with unrestricted access"); + bail!( + "No trusted applications resolved — refusing to create a keychain entry with unrestricted access" + ); } for path in trusted_apps { args.push("-T".to_string()); @@ -68,7 +73,10 @@ fn security_delete(service: &str) -> Result<()> { if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - bail!("Failed to delete credential from keychain: {}", stderr.trim()); + bail!( + "Failed to delete credential from keychain: {}", + stderr.trim() + ); } Ok(()) @@ -84,10 +92,10 @@ fn trusted_app_paths() -> Vec { } // claudini binary (ourselves) - if let Ok(exe) = std::env::current_exe() { - if let Ok(canonical) = exe.canonicalize() { - paths.push(canonical.to_string_lossy().into_owned()); - } + if let Ok(exe) = std::env::current_exe() + && let Ok(canonical) = exe.canonicalize() + { + paths.push(canonical.to_string_lossy().into_owned()); } // security binary (Claude Code may use it to read the credential) diff --git a/src/main.rs b/src/main.rs index 5c33dac..cad1eff 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,14 +7,18 @@ mod update; use std::path::PathBuf; -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result, bail}; use clap::{Parser, Subcommand}; -use comfy_table::{presets::UTF8_FULL_CONDENSED, Cell, Color, Table}; +use comfy_table::{Cell, Color, Table, presets::UTF8_FULL_CONDENSED}; use console::style; use indicatif::{ProgressBar, ProgressStyle}; #[derive(Parser)] -#[command(name = "claudini", about = "CLI for switching Claude Code accounts", version)] +#[command( + name = "claudini", + about = "CLI for switching Claude Code accounts", + version +)] struct Cli { /// Output as JSON (machine-readable, no colors/spinners) #[arg(long, global = true)] @@ -154,10 +158,7 @@ fn run(cli: Cli) -> Result<()> { let (name, email) = profile::current(&claudini_dir, &claude_home)?; if is_json { - println!( - "{}", - serde_json::json!({ "profile": name, "email": email }) - ); + println!("{}", serde_json::json!({ "profile": name, "email": email })); } else { println!("{} {}", style("Profile:").bold(), style(&name).cyan()); match email { @@ -170,52 +171,50 @@ fn run(cli: Cli) -> Result<()> { }; match command { - Command::Init => { - match profile::init(&claudini_dir)? { - profile::InitResult::Initialized => { - if is_json { - println!("{}", serde_json::json!({ "status": "initialized" })); - } else { - println!( - "{} Initialized claudini at {}", - style("✓").green().bold(), - style(claudini_dir.display()).cyan() - ); - } + Command::Init => match profile::init(&claudini_dir)? { + profile::InitResult::Initialized => { + if is_json { + println!("{}", serde_json::json!({ "status": "initialized" })); + } else { + println!( + "{} Initialized claudini at {}", + style("✓").green().bold(), + style(claudini_dir.display()).cyan() + ); } - profile::InitResult::AlreadyInitialized { - profiles_migrated, - backups_migrated, - } => { - let total = profiles_migrated + backups_migrated; - if is_json { - println!( - "{}", - serde_json::json!({ - "status": "already_initialized", - "credentials_migrated": { - "profiles": profiles_migrated, - "backups": backups_migrated, - } - }) - ); - } else if total > 0 { - println!( - "{} Already initialized. Migrated {} credential(s) to Keychain ({} profile, {} backup).", - style("✓").green().bold(), - total, - profiles_migrated, - backups_migrated - ); - } else { - println!( - "{} Already initialized (no legacy credentials to migrate).", - style("✓").green().bold(), - ); - } + } + profile::InitResult::AlreadyInitialized { + profiles_migrated, + backups_migrated, + } => { + let total = profiles_migrated + backups_migrated; + if is_json { + println!( + "{}", + serde_json::json!({ + "status": "already_initialized", + "credentials_migrated": { + "profiles": profiles_migrated, + "backups": backups_migrated, + } + }) + ); + } else if total > 0 { + println!( + "{} Already initialized. Migrated {} credential(s) to Keychain ({} profile, {} backup).", + style("✓").green().bold(), + total, + profiles_migrated, + backups_migrated + ); + } else { + println!( + "{} Already initialized (no legacy credentials to migrate).", + style("✓").green().bold(), + ); } } - } + }, Command::Use { name, launch } => { switch_profile(&claudini_dir, &claude_home, &name, launch, is_json)?; @@ -224,7 +223,10 @@ fn run(cli: Cli) -> Result<()> { Command::Run(args) => { let name = args.first().context("Profile name required")?; if args.len() > 1 { - bail!("Unexpected arguments after profile name: {}", args[1..].join(" ")); + bail!( + "Unexpected arguments after profile name: {}", + args[1..].join(" ") + ); } switch_profile(&claudini_dir, &claude_home, name, true, is_json)?; } @@ -269,9 +271,7 @@ fn run(cli: Cli) -> Result<()> { if is_json { let items: Vec<_> = profiles .iter() - .map(|(name, active)| { - serde_json::json!({ "name": name, "active": active }) - }) + .map(|(name, active)| serde_json::json!({ "name": name, "active": active })) .collect(); println!("{}", serde_json::to_string_pretty(&items)?); } else if profiles.is_empty() { @@ -333,10 +333,7 @@ fn run(cli: Cli) -> Result<()> { let (name, email) = profile::current(&claudini_dir, &claude_home)?; if is_json { - println!( - "{}", - serde_json::json!({ "profile": name, "email": email }) - ); + println!("{}", serde_json::json!({ "profile": name, "email": email })); } else { println!("{} {}", style("Profile:").bold(), style(&name).cyan()); match email { @@ -417,7 +414,6 @@ fn run(cli: Cli) -> Result<()> { } } }, - } Ok(()) diff --git a/src/profile.rs b/src/profile.rs index f4bd9e3..aeb94d6 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -1,12 +1,12 @@ use std::path::Path; -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result, bail}; use serde_json::Value; use crate::backup; use crate::config::{ - claude_json_path, is_initialized, list_profiles, profile_claude_json, - profile_dir, profiles_dir, Config, + Config, claude_json_path, is_initialized, list_profiles, profile_claude_json, profile_dir, + profiles_dir, }; use crate::keychain; use crate::sync::sync_shared_fields; @@ -100,10 +100,10 @@ pub fn add_with_login(claudini_dir: &Path, claude_home: &Path, name: &str) -> Re let cfg = Config::load(claudini_dir)?; // Save current keychain credential to current active profile (if any) - if let Some(ref active) = cfg.active_profile { - if let Ok(cred) = keychain::read() { - let _ = keychain::write_profile(active, &cred); - } + if let Some(ref active) = cfg.active_profile + && let Ok(cred) = keychain::read() + { + let _ = keychain::write_profile(active, &cred); } // Remove symlink / file so claude starts fresh @@ -168,21 +168,10 @@ pub fn switch(claudini_dir: &Path, claude_home: &Path, name: &str) -> Result<()> } // Sync shared fields from outgoing → incoming - if active != name { - if let Ok(from_data) = std::fs::read_to_string(&cj) { - if let Ok(from_val) = serde_json::from_str::(&from_data) { - let target_path = profile_claude_json(claudini_dir, name); - if let Ok(to_data) = std::fs::read_to_string(&target_path) { - if let Ok(mut to_val) = serde_json::from_str::(&to_data) { - sync_shared_fields(&from_val, &mut to_val); - let _ = std::fs::write( - &target_path, - serde_json::to_string_pretty(&to_val)?, - ); - } - } - } - } + if active != name + && let Err(e) = sync_shared_fields_between(claudini_dir, &cj, name) + { + eprintln!("warning: failed to sync shared fields: {e:#}"); } } @@ -253,7 +242,12 @@ pub fn remove(claudini_dir: &Path, name: &str) -> Result<()> { } /// Rename a profile. -pub fn rename(claudini_dir: &Path, claude_home: &Path, old_name: &str, new_name: &str) -> Result<()> { +pub fn rename( + claudini_dir: &Path, + claude_home: &Path, + old_name: &str, + new_name: &str, +) -> Result<()> { ensure_initialized(claudini_dir)?; let old_dir = profile_dir(claudini_dir, old_name); @@ -326,13 +320,12 @@ pub fn migrate_all_profile_credentials(claudini_dir: &Path) -> usize { let mut count = 0; for name in &profiles { let cred_file = profile_dir(claudini_dir, name).join("credentials"); - if cred_file.is_file() { - if let Ok(cred) = std::fs::read_to_string(&cred_file) { - if keychain::write_profile(name, &cred).is_ok() { - let _ = std::fs::remove_file(&cred_file); - count += 1; - } - } + if cred_file.is_file() + && let Ok(cred) = std::fs::read_to_string(&cred_file) + && keychain::write_profile(name, &cred).is_ok() + { + let _ = std::fs::remove_file(&cred_file); + count += 1; } } count @@ -340,15 +333,33 @@ pub fn migrate_all_profile_credentials(claudini_dir: &Path) -> usize { fn migrate_profile_credential_if_needed(claudini_dir: &Path, name: &str) { let cred_file = profile_dir(claudini_dir, name).join("credentials"); - if cred_file.is_file() { - if let Ok(cred) = std::fs::read_to_string(&cred_file) { - if keychain::write_profile(name, &cred).is_ok() { - let _ = std::fs::remove_file(&cred_file); - } - } + if cred_file.is_file() + && let Ok(cred) = std::fs::read_to_string(&cred_file) + && keychain::write_profile(name, &cred).is_ok() + { + let _ = std::fs::remove_file(&cred_file); } } +fn sync_shared_fields_between(claudini_dir: &Path, cj: &Path, name: &str) -> Result<()> { + let from_data = + std::fs::read_to_string(cj).context("Failed to read outgoing profile's claude.json")?; + let from_val: Value = serde_json::from_str(&from_data) + .context("Failed to parse outgoing profile's claude.json")?; + + let target_path = profile_claude_json(claudini_dir, name); + let to_data = std::fs::read_to_string(&target_path) + .with_context(|| format!("Failed to read incoming profile '{}' claude.json", name))?; + let mut to_val: Value = serde_json::from_str(&to_data) + .with_context(|| format!("Failed to parse incoming profile '{}' claude.json", name))?; + + sync_shared_fields(&from_val, &mut to_val); + std::fs::write(&target_path, serde_json::to_string_pretty(&to_val)?) + .context("Failed to write synced claude.json")?; + + Ok(()) +} + fn read_email_from_claude_json(path: &Path) -> Option { let data = std::fs::read_to_string(path).ok()?; let val: Value = serde_json::from_str(&data).ok()?; diff --git a/src/update.rs b/src/update.rs index 01ecd8a..82fd31e 100644 --- a/src/update.rs +++ b/src/update.rs @@ -51,7 +51,10 @@ fn fetch_latest_version() -> Option { } let release: GitHubRelease = serde_json::from_slice(&output.stdout).ok()?; - let version = release.tag_name.strip_prefix('v').unwrap_or(&release.tag_name); + let version = release + .tag_name + .strip_prefix('v') + .unwrap_or(&release.tag_name); Some(version.to_string()) } @@ -61,7 +64,10 @@ fn print_update_notice(current: &str, latest: &str) { style("Update available:").yellow().bold(), style(current).dim(), style(latest).green().bold(), - style("curl -fsSL https://raw.githubusercontent.com/kimrgrey/claudini/main/install.sh | sh").cyan(), + style( + "curl -fsSL https://raw.githubusercontent.com/kimrgrey/claudini/main/install.sh | sh" + ) + .cyan(), ); } diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..cff6a54 --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,123 @@ +use assert_cmd::cargo::cargo_bin_cmd; +use assert_cmd::Command; +use predicates::prelude::*; + +fn claudini() -> Command { + cargo_bin_cmd!("claudini") +} + +#[test] +fn version_flag() { + claudini() + .arg("--version") + .assert() + .success() + .stdout(predicate::str::starts_with("claudini ")); +} + +#[test] +fn help_flag() { + claudini() + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("CLI for switching Claude Code accounts")); +} + +#[test] +fn profile_help() { + claudini() + .args(["profile", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("add")) + .stdout(predicate::str::contains("use")) + .stdout(predicate::str::contains("list")) + .stdout(predicate::str::contains("remove")) + .stdout(predicate::str::contains("rename")) + .stdout(predicate::str::contains("current")); +} + +#[test] +fn backup_help() { + claudini() + .args(["backup", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("create")) + .stdout(predicate::str::contains("restore")) + .stdout(predicate::str::contains("delete")) + .stdout(predicate::str::contains("list")); +} + +#[test] +fn profile_add_missing_name() { + claudini() + .args(["profile", "add"]) + .assert() + .failure() + .stderr(predicate::str::contains("required")); +} + +#[test] +fn profile_remove_missing_name() { + claudini() + .args(["profile", "remove"]) + .assert() + .failure() + .stderr(predicate::str::contains("required")); +} + +#[test] +fn profile_rename_missing_args() { + claudini() + .args(["profile", "rename"]) + .assert() + .failure() + .stderr(predicate::str::contains("required")); +} + +#[test] +fn backup_create_missing_name() { + claudini() + .args(["backup", "create"]) + .assert() + .failure() + .stderr(predicate::str::contains("required")); +} + +#[test] +fn backup_restore_missing_name() { + claudini() + .args(["backup", "restore"]) + .assert() + .failure() + .stderr(predicate::str::contains("required")); +} + +#[test] +fn backup_delete_missing_name() { + claudini() + .args(["backup", "delete"]) + .assert() + .failure() + .stderr(predicate::str::contains("required")); +} + +#[test] +fn json_flag_on_error() { + claudini() + .args(["--json", "profile", "current"]) + .env("HOME", "/tmp/claudini-test-nonexistent") + .assert() + .failure() + .stdout(predicate::str::contains("\"error\"")); +} + +#[test] +fn unknown_subcommand_args_rejected() { + claudini() + .args(["someprofile", "extra", "args"]) + .assert() + .failure(); +} From d0ae85640b08b7ff187bfa93a94fb753c68643c3 Mon Sep 17 00:00:00 2001 From: Sergei Tsvetkov Date: Sun, 1 Mar 2026 00:13:54 +0100 Subject: [PATCH 2/4] Add CI workflow for lint and tests --- .github/workflows/ci.yml | 59 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a1269be --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,59 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + run: | + rustup toolchain install stable --profile minimal + rustup component add rustfmt clippy + + - name: Cache cargo registry & build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('Cargo.lock') }} + restore-keys: ${{ runner.os }}-cargo- + + - name: Formatting + run: cargo fmt -- --check + + - name: Clippy + run: cargo clippy --all-targets -- -D warnings + + test: + needs: lint + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + run: rustup toolchain install stable --profile minimal + + - name: Cache cargo registry & build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('Cargo.lock') }} + restore-keys: ${{ runner.os }}-cargo- + + - name: Build + run: cargo build --all-targets + + - name: Test + run: cargo test From e537b0fe4323308cc66e6ccaa28c7d02893ab358 Mon Sep 17 00:00:00 2001 From: Sergei Tsvetkov Date: Sun, 1 Mar 2026 00:16:07 +0100 Subject: [PATCH 3/4] Fix rustfmt formatting in tests, add lint convention to CLAUDE.md --- CLAUDE.md | 1 + tests/cli.rs | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4c27cda..5eafc5e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,6 +66,7 @@ claudini backup list - Try to keep branch names short but readable - Always remove previously added but now unused code - Do not include test plans in PR descriptions or commit messages +- Before committing, always run `cargo fmt` and `cargo clippy --all-targets -- -D warnings` and fix any issues ## Build & run diff --git a/tests/cli.rs b/tests/cli.rs index cff6a54..cbea6cf 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,5 +1,5 @@ -use assert_cmd::cargo::cargo_bin_cmd; use assert_cmd::Command; +use assert_cmd::cargo::cargo_bin_cmd; use predicates::prelude::*; fn claudini() -> Command { @@ -21,7 +21,9 @@ fn help_flag() { .arg("--help") .assert() .success() - .stdout(predicate::str::contains("CLI for switching Claude Code accounts")); + .stdout(predicate::str::contains( + "CLI for switching Claude Code accounts", + )); } #[test] From aa0d3bdcf5b9a9f9fffc8fd98e3ebd63fad1d0b9 Mon Sep 17 00:00:00 2001 From: Sergei Tsvetkov Date: Sun, 1 Mar 2026 00:25:41 +0100 Subject: [PATCH 4/4] Strip ANSI colors when stdout is not a terminal Use console::set_colors_enabled() to globally disable styling when stdout is piped or redirected. Conditionally skip comfy_table .fg() calls so table output is also clean. --- CLAUDE.md | 1 + src/main.rs | 15 +++++++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5eafc5e..8ead673 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,6 +54,7 @@ claudini backup list - Two output modes: human (colored, spinners, tables) and JSON (`--json` flag) - Claude home directory overridable via `--claude-home` flag or `CLAUDINI_CLAUDE_HOME` env var - Account-specific fields listed in `sync.rs::ACCOUNT_SPECIFIC_FIELDS` +- When stdout is not a terminal (piped/redirected), colors should be stripped from table output and `console::Style` is skipped entirely. Redirecting command output to a file should produce clean text. ## Git & PR conventions diff --git a/src/main.rs b/src/main.rs index cad1eff..15d7b1f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ mod profile; mod sync; mod update; +use std::io::IsTerminal; use std::path::PathBuf; use anyhow::{Context, Result, bail}; @@ -152,6 +153,8 @@ fn run(cli: Cli) -> Result<()> { let claude_home = config::resolve_claude_home(cli.claude_home.as_deref())?; let is_json = cli.json; + console::set_colors_enabled(!is_json && std::io::stdout().is_terminal()); + let command = match cli.command { Some(cmd) => cmd, None => { @@ -280,12 +283,16 @@ fn run(cli: Cli) -> Result<()> { let mut table = Table::new(); table.load_preset(UTF8_FULL_CONDENSED); table.set_header(vec!["Profile", "Status"]); + let use_color = console::colors_enabled(); for (name, active) in &profiles { if *active { - table.add_row(vec![ - Cell::new(name).fg(Color::Cyan), - Cell::new("active").fg(Color::Green), - ]); + let mut name_cell = Cell::new(name); + let mut status_cell = Cell::new("active"); + if use_color { + name_cell = name_cell.fg(Color::Cyan); + status_cell = status_cell.fg(Color::Green); + } + table.add_row(vec![name_cell, status_cell]); } else { table.add_row(vec![Cell::new(name), Cell::new("")]); }