Skip to main content

ai_usagebar/anthropic/
cli_account.rs

1//! Which Claude account the `claude` **CLI** is signed into, and moving that
2//! login between managed accounts.
3//!
4//! This is a separate identity from the Claude Desktop app's (see
5//! [`crate::claude_desktop`]) — the two drift apart constantly, which is most
6//! of why they are both worth reporting.
7//!
8//! The CLI keeps exactly **one** default login: `~/.claude/.credentials.json`
9//! on Linux, the login-Keychain item `Claude Code-credentials` on macOS. A
10//! named account instead lives under its own `CLAUDE_CONFIG_DIR`, in the
11//! per-directory Keychain item [`crate::anthropic::keychain`] resolves. Making
12//! a named account "the one plain `claude` uses" therefore means moving its
13//! credential into that single default slot.
14//!
15//! Copying would leave both slots holding the same *rotating* refresh token,
16//! and whichever client refreshed first would invalidate the other — the
17//! failure [`crate::anthropic::creds`] documents. The switch therefore moves
18//! the credential: it captures the outgoing default credential back into its
19//! named slot first, installs the target in the default slot, and removes the
20//! target's named copy. In addition,
21//! [`crate::config::AnthropicConfig::account_target_with`] routes reads for
22//! whichever label is *currently* active to the default slot, so only one copy
23//! is ever live.
24//!
25//! Everything except [`KeychainStore`] is platform-agnostic and unit-tested
26//! against a fake store, so the logic stays under CI's linter on Linux.
27
28use std::path::Path;
29use std::time::Duration;
30
31use serde_json::{Map, Value};
32
33use crate::config::AnthropicAccount;
34use crate::error::{AppError, Result};
35
36/// The `claude` CLI's per-config-dir state file. Holds a plaintext
37/// `oauthAccount` identity marker (uuid, email, org) — never a token.
38const CLAUDE_JSON: &str = ".claude.json";
39
40/// Read/write access to the credential slots. Abstracted so the switch logic
41/// can be tested without a macOS Keychain.
42pub trait CredentialStore {
43    fn read_default(&self) -> Result<Option<String>>;
44    fn write_default(&self, blob: &str) -> Result<()>;
45    fn delete_default(&self) -> Result<()>;
46    fn read_named(&self, config_dir: &Path) -> Result<Option<String>>;
47    fn write_named(&self, config_dir: &Path, blob: &str) -> Result<()>;
48    fn delete_named(&self, config_dir: &Path) -> Result<()>;
49}
50
51/// The real store. The `#[cfg]` pairs are confined here so no other logic in
52/// this module is invisible to a Linux build — the same shape
53/// [`crate::anthropic::creds::resolve`] uses.
54pub struct KeychainStore;
55
56/// Path to the home `.claude.json` that records the live CLI identity.
57pub fn home_claude_json() -> Result<std::path::PathBuf> {
58    Ok(crate::cache::home_dir()?.join(CLAUDE_JSON))
59}
60
61/// The identity marker inside an account's own `CLAUDE_CONFIG_DIR`.
62pub fn marker_path(config_dir: &Path) -> std::path::PathBuf {
63    config_dir.join(CLAUDE_JSON)
64}
65
66/// The account UUID recorded in a `.claude.json`, if any.
67pub fn account_uuid_in(claude_json: &Path) -> Option<String> {
68    oauth_account_in(claude_json)?
69        .get("accountUuid")?
70        .as_str()
71        .filter(|uuid| !uuid.is_empty())
72        .map(str::to_string)
73}
74
75/// The e-mail recorded alongside it, for display only.
76pub fn account_email_in(claude_json: &Path) -> Option<String> {
77    oauth_account_in(claude_json)?
78        .get("emailAddress")?
79        .as_str()
80        .filter(|email| !email.is_empty())
81        .map(str::to_string)
82}
83
84/// Which managed label the live CLI login belongs to.
85///
86/// `None` when the CLI is signed into something we do not manage — a plain
87/// `claude` login done by hand, say. Callers surface that as "unknown" rather
88/// than hiding it: it is exactly the case where the named copies may have gone
89/// stale behind our back.
90pub fn resolve_active_label(
91    home_claude_json: &Path,
92    accounts: &[AnthropicAccount],
93) -> Option<String> {
94    let live = account_uuid_in(home_claude_json)?;
95    accounts
96        .iter()
97        .find(|account| {
98            account_uuid_in(&account.config_dir().join(CLAUDE_JSON)) == Some(live.clone())
99        })
100        .map(|account| account.label.clone())
101}
102
103/// Set (or clear) the `oauthAccount` marker in a `.claude.json`, preserving
104/// every other key. That document is tens of kilobytes of unrelated CLI state,
105/// so it is merged into, never replaced.
106pub fn merge_oauth_account(existing: &[u8], oauth_account: Option<&Value>) -> Result<Vec<u8>> {
107    let mut document: Value = if existing.iter().all(u8::is_ascii_whitespace) {
108        Value::Object(Map::new())
109    } else {
110        serde_json::from_slice(existing)?
111    };
112    let object = document
113        .as_object_mut()
114        .ok_or_else(|| AppError::Other(format!("{CLAUDE_JSON} is not a JSON object")))?;
115    match oauth_account {
116        Some(account) => {
117            object.insert("oauthAccount".into(), account.clone());
118        }
119        // Better an absent marker than one that names the previous account.
120        None => {
121            object.remove("oauthAccount");
122        }
123    }
124    Ok(serde_json::to_vec(&document)?)
125}
126
127#[derive(Debug, Clone, Copy, Default)]
128pub struct CliSwitchOpts {
129    /// Overwrite the default slot even though the live login belongs to no
130    /// managed account. That login cannot be captured anywhere first, so this
131    /// genuinely destroys it.
132    pub force: bool,
133    /// Validate everything and report, without writing.
134    pub dry_run: bool,
135}
136
137#[derive(Debug, PartialEq, Eq)]
138pub enum CliSwitchOutcome {
139    AlreadyActive,
140    /// The account was already active, and a redundant named Keychain copy was
141    /// removed so the rotating token again has one live lineage.
142    RemovedDuplicate,
143    /// Dry-run counterpart of [`Self::RemovedDuplicate`].
144    WouldRemoveDuplicate,
145    /// The identity marker named this account but the default credential slot
146    /// was empty; its named credential was moved into the default slot.
147    RepairedActive,
148    /// `outgoing` is the label whose credential was captured back first, when
149    /// there was one.
150    Switched {
151        outgoing: Option<String>,
152    },
153    /// `dry_run` was set; nothing was written.
154    WouldSwitch {
155        outgoing: Option<String>,
156    },
157}
158
159/// Make `label` the account plain `claude` uses.
160///
161/// Ordering is deliberate. The outgoing credential is captured back **before**
162/// anything is overwritten — that is the only irreversible step, and a failure
163/// there aborts. The default slot is then written **before** the identity
164/// marker, because the credential is the source of truth: a marker claiming an
165/// account whose token is not there is worse than the reverse.
166pub fn switch_cli_account(
167    home_claude_json: &Path,
168    accounts: &[AnthropicAccount],
169    label: &str,
170    opts: CliSwitchOpts,
171    store: &dyn CredentialStore,
172) -> Result<CliSwitchOutcome> {
173    let lock_path = home_claude_json
174        .parent()
175        .unwrap_or_else(|| Path::new("."))
176        .join(".ai-usagebar-account-switch.lock");
177    let _lock = crate::cache::acquire_lock(&lock_path, Duration::from_secs(2))?;
178
179    let target = accounts
180        .iter()
181        .find(|account| account.label == label)
182        .ok_or_else(|| {
183            let known: Vec<&str> = accounts.iter().map(|a| a.label.as_str()).collect();
184            AppError::Credentials(format!(
185                "no Claude CLI account {label:?} in [[anthropic.accounts]] or accounts_dir; \
186                 known: {known:?}"
187            ))
188        })?;
189
190    let active = resolve_active_label(home_claude_json, accounts);
191
192    // Read every piece needed for both the move and its rollback before the
193    // first write. An empty default slot is safe to populate without --force;
194    // an unrecognised *existing* login is the destructive case.
195    let original_default = store.read_default()?;
196    if active.as_deref() == Some(label) && original_default.is_some() {
197        if store.read_named(&target.config_dir())?.is_none() {
198            return Ok(CliSwitchOutcome::AlreadyActive);
199        }
200        if opts.dry_run {
201            return Ok(CliSwitchOutcome::WouldRemoveDuplicate);
202        }
203        store.delete_named(&target.config_dir())?;
204        return Ok(CliSwitchOutcome::RemovedDuplicate);
205    }
206    if active.is_none() && original_default.is_some() && !opts.force {
207        return Err(AppError::Credentials(format!(
208            "the `claude` CLI is signed into an account that is not managed here, so \
209             switching to {label:?} would overwrite a login that cannot be saved first. \
210             Register it with `ai-usagebar account add <label>`, or pass --force to \
211             discard it."
212        )));
213    }
214
215    // Fail before touching anything if the target has never been signed in.
216    let target_blob = store.read_named(&target.config_dir())?.ok_or_else(|| {
217        AppError::Credentials(format!(
218            "no stored credential for {label:?}; sign it in once with \
219             `ai-usagebar account add {label}`"
220        ))
221    })?;
222    let oauth_account =
223        oauth_account_in(&target.config_dir().join(CLAUDE_JSON)).ok_or_else(|| {
224            AppError::Credentials(format!(
225                "the identity marker for {label:?} is missing or invalid; sign it in again with \
226             `ai-usagebar account add {label}` before switching"
227            ))
228        })?;
229    let original_marker = read_optional(home_claude_json)?;
230    let merged_marker = merge_oauth_account(
231        original_marker.as_deref().unwrap_or_default(),
232        Some(&oauth_account),
233    )?;
234
235    let outgoing_account = active
236        .as_deref()
237        .and_then(|outgoing| accounts.iter().find(|account| account.label == outgoing));
238    let original_outgoing_named = match outgoing_account {
239        Some(account) => store.read_named(&account.config_dir())?,
240        None => None,
241    };
242
243    if opts.dry_run {
244        return Ok(CliSwitchOutcome::WouldSwitch { outgoing: active });
245    }
246
247    let rollback = RollbackState {
248        target,
249        target_blob: &target_blob,
250        outgoing: outgoing_account,
251        original_outgoing_named: original_outgoing_named.as_deref(),
252        original_default: original_default.as_deref(),
253        marker_path: home_claude_json,
254        original_marker: original_marker.as_deref(),
255    };
256
257    if let (Some(account), Some(blob)) = (outgoing_account, original_default.as_deref())
258        && let Err(error) = store.write_named(&account.config_dir(), blob)
259    {
260        return Err(with_rollback(error, rollback_switch(store, &rollback)));
261    }
262    if let Err(error) = store.write_default(&target_blob) {
263        return Err(with_rollback(error, rollback_switch(store, &rollback)));
264    }
265
266    if let Err(error) = crate::cache::atomic_write(home_claude_json, &merged_marker) {
267        return Err(with_rollback(error, rollback_switch(store, &rollback)));
268    }
269    if let Err(error) = store.delete_named(&target.config_dir()) {
270        return Err(with_rollback(error, rollback_switch(store, &rollback)));
271    }
272
273    if active.as_deref() == Some(label) {
274        Ok(CliSwitchOutcome::RepairedActive)
275    } else {
276        Ok(CliSwitchOutcome::Switched { outgoing: active })
277    }
278}
279
280struct RollbackState<'a> {
281    target: &'a AnthropicAccount,
282    target_blob: &'a str,
283    outgoing: Option<&'a AnthropicAccount>,
284    original_outgoing_named: Option<&'a str>,
285    original_default: Option<&'a str>,
286    marker_path: &'a Path,
287    original_marker: Option<&'a [u8]>,
288}
289
290fn rollback_switch(store: &dyn CredentialStore, state: &RollbackState<'_>) -> Result<()> {
291    let mut failures = Vec::new();
292    if let Err(error) = restore_default(store, state.original_default) {
293        failures.push(format!("default credential: {error}"));
294    }
295    // Recreate it unconditionally: a failed Keychain delete can still have
296    // removed the item before returning its error.
297    if let Err(error) = store.write_named(&state.target.config_dir(), state.target_blob) {
298        failures.push(format!("target credential: {error}"));
299    }
300    if let Some(account) = state.outgoing
301        && let Err(error) =
302            restore_named(store, &account.config_dir(), state.original_outgoing_named)
303    {
304        failures.push(format!("outgoing credential: {error}"));
305    }
306    if let Err(error) = restore_marker(state.marker_path, state.original_marker) {
307        failures.push(format!("identity marker: {error}"));
308    }
309    if failures.is_empty() {
310        Ok(())
311    } else {
312        Err(AppError::Other(failures.join("; ")))
313    }
314}
315
316fn restore_default(store: &dyn CredentialStore, original: Option<&str>) -> Result<()> {
317    match original {
318        Some(blob) => store.write_default(blob),
319        None => store.delete_default(),
320    }
321}
322
323fn restore_named(store: &dyn CredentialStore, path: &Path, original: Option<&str>) -> Result<()> {
324    match original {
325        Some(blob) => store.write_named(path, blob),
326        None => store.delete_named(path),
327    }
328}
329
330fn restore_marker(path: &Path, original: Option<&[u8]>) -> Result<()> {
331    if let Some(bytes) = original {
332        crate::cache::atomic_write(path, bytes)
333    } else {
334        match std::fs::remove_file(path) {
335            Ok(()) => Ok(()),
336            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
337            Err(error) => Err(AppError::io_at(path, error)),
338        }
339    }
340}
341
342fn read_optional(path: &Path) -> Result<Option<Vec<u8>>> {
343    match std::fs::read(path) {
344        Ok(bytes) => Ok(Some(bytes)),
345        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
346        Err(error) => Err(AppError::io_at(path, error)),
347    }
348}
349
350fn with_rollback(error: AppError, rollback: Result<()>) -> AppError {
351    match rollback {
352        Ok(()) => error,
353        Err(rollback) => AppError::Other(format!(
354            "{error}; automatic rollback was incomplete: {rollback}"
355        )),
356    }
357}
358
359fn oauth_account_in(claude_json: &Path) -> Option<Value> {
360    let bytes = std::fs::read(claude_json).ok()?;
361    let document: Value = serde_json::from_slice(&bytes).ok()?;
362    document
363        .get("oauthAccount")
364        .filter(|v| v.is_object())
365        .cloned()
366}
367
368#[cfg(not(target_os = "macos"))]
369fn unsupported() -> AppError {
370    AppError::Credentials(
371        "switching the `claude` CLI login is supported on macOS only (elsewhere, run \
372         `CLAUDE_CONFIG_DIR=<account dir> claude` to use a specific account)"
373            .into(),
374    )
375}
376
377impl CredentialStore for KeychainStore {
378    fn read_default(&self) -> Result<Option<String>> {
379        #[cfg(target_os = "macos")]
380        return super::keychain::read_raw();
381        #[cfg(not(target_os = "macos"))]
382        Err(unsupported())
383    }
384
385    fn write_default(&self, blob: &str) -> Result<()> {
386        #[cfg(target_os = "macos")]
387        return super::keychain::write_raw(blob);
388        #[cfg(not(target_os = "macos"))]
389        {
390            let _ = blob;
391            Err(unsupported())
392        }
393    }
394
395    fn delete_default(&self) -> Result<()> {
396        #[cfg(target_os = "macos")]
397        return super::keychain::delete_raw();
398        #[cfg(not(target_os = "macos"))]
399        Err(unsupported())
400    }
401
402    fn read_named(&self, config_dir: &Path) -> Result<Option<String>> {
403        #[cfg(target_os = "macos")]
404        return super::keychain::read_raw_for(config_dir);
405        #[cfg(not(target_os = "macos"))]
406        {
407            let _ = config_dir;
408            Err(unsupported())
409        }
410    }
411
412    fn write_named(&self, config_dir: &Path, blob: &str) -> Result<()> {
413        #[cfg(target_os = "macos")]
414        return super::keychain::write_raw_for(config_dir, blob);
415        #[cfg(not(target_os = "macos"))]
416        {
417            let _ = (config_dir, blob);
418            Err(unsupported())
419        }
420    }
421
422    fn delete_named(&self, config_dir: &Path) -> Result<()> {
423        #[cfg(target_os = "macos")]
424        return super::keychain::delete_raw_for(config_dir);
425        #[cfg(not(target_os = "macos"))]
426        {
427            let _ = config_dir;
428            Err(unsupported())
429        }
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436    use std::cell::RefCell;
437    use std::collections::BTreeMap;
438    use std::path::PathBuf;
439
440    #[derive(Default)]
441    struct FakeStore {
442        default: RefCell<Option<String>>,
443        named: RefCell<BTreeMap<PathBuf, String>>,
444        fail_delete_named: RefCell<Option<PathBuf>>,
445    }
446
447    impl CredentialStore for FakeStore {
448        fn read_default(&self) -> Result<Option<String>> {
449            Ok(self.default.borrow().clone())
450        }
451        fn write_default(&self, blob: &str) -> Result<()> {
452            *self.default.borrow_mut() = Some(blob.to_string());
453            Ok(())
454        }
455        fn delete_default(&self) -> Result<()> {
456            *self.default.borrow_mut() = None;
457            Ok(())
458        }
459        fn read_named(&self, config_dir: &Path) -> Result<Option<String>> {
460            Ok(self.named.borrow().get(config_dir).cloned())
461        }
462        fn write_named(&self, config_dir: &Path, blob: &str) -> Result<()> {
463            self.named
464                .borrow_mut()
465                .insert(config_dir.to_path_buf(), blob.to_string());
466            Ok(())
467        }
468        fn delete_named(&self, config_dir: &Path) -> Result<()> {
469            self.named.borrow_mut().remove(config_dir);
470            if self.fail_delete_named.borrow().as_deref() == Some(config_dir) {
471                Err(AppError::Other("injected delete failure".into()))
472            } else {
473                Ok(())
474            }
475        }
476    }
477
478    fn write(path: &Path, contents: &str) {
479        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
480        std::fs::write(path, contents).unwrap();
481    }
482
483    fn marker(uuid: &str, email: &str) -> String {
484        format!(r#"{{"oauthAccount":{{"accountUuid":"{uuid}","emailAddress":"{email}"}}}}"#)
485    }
486
487    struct Fixture {
488        _root: tempfile::TempDir,
489        home: PathBuf,
490        accounts: Vec<AnthropicAccount>,
491        store: FakeStore,
492    }
493
494    /// Two accounts, `work` and `personal`; the CLI is signed into `personal`.
495    fn fixture() -> Fixture {
496        let root = tempfile::TempDir::new().unwrap();
497        let home = root.path().join("home").join(CLAUDE_JSON);
498        write(&home, &marker("uuid-personal", "me@personal.test"));
499
500        let accounts: Vec<AnthropicAccount> = ["work", "personal"]
501            .iter()
502            .map(|label| AnthropicAccount {
503                label: (*label).to_string(),
504                credentials_path: root
505                    .path()
506                    .join("accounts")
507                    .join(label)
508                    .join(".credentials.json"),
509            })
510            .collect();
511        write(
512            &accounts[0].config_dir().join(CLAUDE_JSON),
513            &marker("uuid-work", "me@work.test"),
514        );
515        write(
516            &accounts[1].config_dir().join(CLAUDE_JSON),
517            &marker("uuid-personal", "me@personal.test"),
518        );
519
520        let store = FakeStore::default();
521        *store.default.borrow_mut() = Some("personal-live".into());
522        store
523            .named
524            .borrow_mut()
525            .insert(accounts[0].config_dir(), "work-saved".into());
526        store
527            .named
528            .borrow_mut()
529            .insert(accounts[1].config_dir(), "personal-stale".into());
530
531        Fixture {
532            _root: root,
533            home,
534            accounts,
535            store,
536        }
537    }
538
539    #[test]
540    fn the_live_login_resolves_to_its_label() {
541        let f = fixture();
542        assert_eq!(
543            resolve_active_label(&f.home, &f.accounts).as_deref(),
544            Some("personal")
545        );
546        assert_eq!(
547            account_email_in(&f.home).as_deref(),
548            Some("me@personal.test")
549        );
550    }
551
552    #[test]
553    fn an_unmanaged_login_resolves_to_nothing() {
554        let f = fixture();
555        write(&f.home, &marker("uuid-stranger", "who@example.test"));
556        assert_eq!(resolve_active_label(&f.home, &f.accounts), None);
557    }
558
559    #[test]
560    fn a_missing_marker_file_is_not_an_error() {
561        let f = fixture();
562        std::fs::remove_file(&f.home).unwrap();
563        assert_eq!(resolve_active_label(&f.home, &f.accounts), None);
564        assert_eq!(account_uuid_in(&f.home), None);
565    }
566
567    #[test]
568    fn switching_captures_the_outgoing_credential_first() {
569        let f = fixture();
570
571        let outcome = switch_cli_account(
572            &f.home,
573            &f.accounts,
574            "work",
575            CliSwitchOpts::default(),
576            &f.store,
577        )
578        .unwrap();
579
580        assert_eq!(
581            outcome,
582            CliSwitchOutcome::Switched {
583                outgoing: Some("personal".into())
584            }
585        );
586        // The freshest lineage was written back into personal's own slot before
587        // the default slot was overwritten.
588        assert_eq!(
589            f.store.named.borrow()[&f.accounts[1].config_dir()],
590            "personal-live"
591        );
592        assert_eq!(f.store.default.borrow().as_deref(), Some("work-saved"));
593        assert!(
594            !f.store
595                .named
596                .borrow()
597                .contains_key(&f.accounts[0].config_dir()),
598            "the active credential must be moved, not left as a rotating-token copy"
599        );
600        assert_eq!(
601            resolve_active_label(&f.home, &f.accounts).as_deref(),
602            Some("work")
603        );
604    }
605
606    #[test]
607    fn switching_is_idempotent() {
608        let f = fixture();
609        let outcome = switch_cli_account(
610            &f.home,
611            &f.accounts,
612            "personal",
613            CliSwitchOpts::default(),
614            &f.store,
615        )
616        .unwrap();
617        assert_eq!(outcome, CliSwitchOutcome::RemovedDuplicate);
618        assert_eq!(f.store.default.borrow().as_deref(), Some("personal-live"));
619        assert!(
620            !f.store
621                .named
622                .borrow()
623                .contains_key(&f.accounts[1].config_dir())
624        );
625
626        let second = switch_cli_account(
627            &f.home,
628            &f.accounts,
629            "personal",
630            CliSwitchOpts::default(),
631            &f.store,
632        )
633        .unwrap();
634        assert_eq!(second, CliSwitchOutcome::AlreadyActive);
635    }
636
637    #[test]
638    fn an_active_marker_with_an_empty_default_slot_is_repaired() {
639        let f = fixture();
640        *f.store.default.borrow_mut() = None;
641
642        let outcome = switch_cli_account(
643            &f.home,
644            &f.accounts,
645            "personal",
646            CliSwitchOpts::default(),
647            &f.store,
648        )
649        .unwrap();
650
651        assert_eq!(outcome, CliSwitchOutcome::RepairedActive);
652        assert_eq!(f.store.default.borrow().as_deref(), Some("personal-stale"));
653        assert!(
654            !f.store
655                .named
656                .borrow()
657                .contains_key(&f.accounts[1].config_dir())
658        );
659    }
660
661    #[test]
662    fn a_dry_run_validates_without_writing() {
663        let f = fixture();
664        let before = std::fs::read(&f.home).unwrap();
665
666        let outcome = switch_cli_account(
667            &f.home,
668            &f.accounts,
669            "work",
670            CliSwitchOpts {
671                dry_run: true,
672                ..CliSwitchOpts::default()
673            },
674            &f.store,
675        )
676        .unwrap();
677
678        assert_eq!(
679            outcome,
680            CliSwitchOutcome::WouldSwitch {
681                outgoing: Some("personal".into())
682            }
683        );
684        assert_eq!(f.store.default.borrow().as_deref(), Some("personal-live"));
685        assert_eq!(std::fs::read(&f.home).unwrap(), before);
686    }
687
688    #[test]
689    fn an_unmanaged_live_login_is_refused_without_force() {
690        let f = fixture();
691        write(&f.home, &marker("uuid-stranger", "who@example.test"));
692
693        let error = switch_cli_account(
694            &f.home,
695            &f.accounts,
696            "work",
697            CliSwitchOpts::default(),
698            &f.store,
699        )
700        .unwrap_err();
701        assert!(error.to_string().contains("--force"), "{error}");
702        assert_eq!(f.store.default.borrow().as_deref(), Some("personal-live"));
703
704        switch_cli_account(
705            &f.home,
706            &f.accounts,
707            "work",
708            CliSwitchOpts {
709                force: true,
710                ..CliSwitchOpts::default()
711            },
712            &f.store,
713        )
714        .unwrap();
715        assert_eq!(f.store.default.borrow().as_deref(), Some("work-saved"));
716        assert!(
717            !f.store
718                .named
719                .borrow()
720                .contains_key(&f.accounts[0].config_dir())
721        );
722    }
723
724    #[test]
725    fn an_empty_default_slot_does_not_require_force() {
726        let f = fixture();
727        *f.store.default.borrow_mut() = None;
728        std::fs::remove_file(&f.home).unwrap();
729
730        switch_cli_account(
731            &f.home,
732            &f.accounts,
733            "work",
734            CliSwitchOpts::default(),
735            &f.store,
736        )
737        .unwrap();
738
739        assert_eq!(f.store.default.borrow().as_deref(), Some("work-saved"));
740        assert_eq!(
741            resolve_active_label(&f.home, &f.accounts).as_deref(),
742            Some("work")
743        );
744    }
745
746    #[test]
747    fn a_target_without_an_identity_marker_changes_nothing() {
748        let f = fixture();
749        std::fs::remove_file(f.accounts[0].config_dir().join(CLAUDE_JSON)).unwrap();
750        let before = std::fs::read(&f.home).unwrap();
751
752        let error = switch_cli_account(
753            &f.home,
754            &f.accounts,
755            "work",
756            CliSwitchOpts::default(),
757            &f.store,
758        )
759        .unwrap_err();
760
761        assert!(error.to_string().contains("identity marker"), "{error}");
762        assert_eq!(f.store.default.borrow().as_deref(), Some("personal-live"));
763        assert_eq!(std::fs::read(&f.home).unwrap(), before);
764    }
765
766    #[test]
767    fn a_late_failure_restores_every_credential_slot_and_marker() {
768        let f = fixture();
769        let before_marker = std::fs::read(&f.home).unwrap();
770        *f.store.fail_delete_named.borrow_mut() = Some(f.accounts[0].config_dir());
771
772        let error = switch_cli_account(
773            &f.home,
774            &f.accounts,
775            "work",
776            CliSwitchOpts::default(),
777            &f.store,
778        )
779        .unwrap_err();
780
781        assert!(
782            error.to_string().contains("injected delete failure"),
783            "{error}"
784        );
785        assert_eq!(f.store.default.borrow().as_deref(), Some("personal-live"));
786        assert_eq!(
787            f.store.named.borrow()[&f.accounts[0].config_dir()],
788            "work-saved"
789        );
790        assert_eq!(
791            f.store.named.borrow()[&f.accounts[1].config_dir()],
792            "personal-stale"
793        );
794        assert_eq!(std::fs::read(&f.home).unwrap(), before_marker);
795    }
796
797    #[test]
798    fn switching_to_an_account_that_never_signed_in_changes_nothing() {
799        let f = fixture();
800        f.store
801            .named
802            .borrow_mut()
803            .remove(&f.accounts[0].config_dir());
804
805        let error = switch_cli_account(
806            &f.home,
807            &f.accounts,
808            "work",
809            CliSwitchOpts::default(),
810            &f.store,
811        )
812        .unwrap_err();
813        assert!(error.to_string().contains("account add work"), "{error}");
814        assert_eq!(f.store.default.borrow().as_deref(), Some("personal-live"));
815    }
816
817    #[test]
818    fn merging_the_marker_preserves_unrelated_state() {
819        let existing = br#"{"firstStartTime":"2026-01-01","oauthAccount":{"accountUuid":"old"},
820            "projects":{"/tmp/x":{"allowedTools":[]}}}"#;
821        let replacement = serde_json::json!({"accountUuid": "new", "emailAddress": "a@b.test"});
822
823        let bytes = merge_oauth_account(existing, Some(&replacement)).unwrap();
824        let value: Value = serde_json::from_slice(&bytes).unwrap();
825        assert_eq!(value["oauthAccount"]["accountUuid"], "new");
826        assert_eq!(value["firstStartTime"], "2026-01-01");
827        assert!(value["projects"]["/tmp/x"].is_object());
828    }
829
830    #[test]
831    fn merging_no_marker_clears_a_stale_one() {
832        let existing = br#"{"oauthAccount":{"accountUuid":"old"},"autoUpdates":true}"#;
833        let bytes = merge_oauth_account(existing, None).unwrap();
834        let value: Value = serde_json::from_slice(&bytes).unwrap();
835        assert!(value.get("oauthAccount").is_none());
836        assert_eq!(value["autoUpdates"], true);
837    }
838
839    #[test]
840    fn merging_into_an_absent_file_starts_a_fresh_document() {
841        let replacement = serde_json::json!({"accountUuid": "new"});
842        let bytes = merge_oauth_account(b"", Some(&replacement)).unwrap();
843        let value: Value = serde_json::from_slice(&bytes).unwrap();
844        assert_eq!(value["oauthAccount"]["accountUuid"], "new");
845    }
846}