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
280/// Register the login plain `claude` is signed into as `account`, without a
281/// new sign-in.
282///
283/// Only the identity marker in the account's own `CLAUDE_CONFIG_DIR` is
284/// written: the rotating credential stays in the default slot alone, and the
285/// first `switch` away from this account captures it into the account's named
286/// slot like any other outgoing login. Signing in again instead would mint a
287/// second, independent grant for the same account.
288pub fn adopt_current(
289    home_claude_json: &Path,
290    accounts: &[AnthropicAccount],
291    account: &AnthropicAccount,
292    store: &dyn CredentialStore,
293) -> Result<()> {
294    // The same lock as `switch_cli_account`, so an adoption and a switch
295    // cannot interleave and both see the same live login.
296    let lock_path = home_claude_json
297        .parent()
298        .unwrap_or_else(|| Path::new("."))
299        .join(".ai-usagebar-account-switch.lock");
300    let _lock = crate::cache::acquire_lock(&lock_path, Duration::from_secs(2))?;
301
302    // One read: the identity checked is the identity written.
303    let live = oauth_account_in(home_claude_json).ok_or_else(|| {
304        AppError::Credentials(
305            "plain `claude` is not signed in, so there is no login to adopt; run `claude` first"
306                .into(),
307        )
308    })?;
309    let live_uuid = live
310        .get("accountUuid")
311        .and_then(Value::as_str)
312        .filter(|uuid| !uuid.is_empty())
313        .map(str::to_string)
314        .ok_or_else(|| {
315            AppError::Credentials("the live `claude` login records no account uuid".into())
316        })?;
317    if let Some(owner) = accounts.iter().find(|other| {
318        other.label != account.label
319            && account_uuid_in(&marker_path(&other.config_dir())).as_deref() == Some(&live_uuid)
320    }) {
321        return Err(AppError::Credentials(format!(
322            "the live `claude` login already belongs to account {:?}",
323            owner.label
324        )));
325    }
326    if store.read_named(&account.config_dir())?.is_some() {
327        return Err(AppError::Credentials(format!(
328            "{:?} already has its own Claude login; adopting would leave two copies of one \
329             account",
330            account.label
331        )));
332    }
333    if store.read_default()?.is_none() {
334        return Err(AppError::Credentials(
335            "the default `claude` credential slot is empty; run `claude` to sign in first".into(),
336        ));
337    }
338    let marker = marker_path(&account.config_dir());
339    let existing = read_optional(&marker)?;
340    let merged = merge_oauth_account(existing.as_deref().unwrap_or_default(), Some(&live))?;
341    crate::cache::atomic_write(&marker, &merged)
342}
343
344struct RollbackState<'a> {
345    target: &'a AnthropicAccount,
346    target_blob: &'a str,
347    outgoing: Option<&'a AnthropicAccount>,
348    original_outgoing_named: Option<&'a str>,
349    original_default: Option<&'a str>,
350    marker_path: &'a Path,
351    original_marker: Option<&'a [u8]>,
352}
353
354fn rollback_switch(store: &dyn CredentialStore, state: &RollbackState<'_>) -> Result<()> {
355    let mut failures = Vec::new();
356    if let Err(error) = restore_default(store, state.original_default) {
357        failures.push(format!("default credential: {error}"));
358    }
359    // Recreate it unconditionally: a failed Keychain delete can still have
360    // removed the item before returning its error.
361    if let Err(error) = store.write_named(&state.target.config_dir(), state.target_blob) {
362        failures.push(format!("target credential: {error}"));
363    }
364    if let Some(account) = state.outgoing
365        && let Err(error) =
366            restore_named(store, &account.config_dir(), state.original_outgoing_named)
367    {
368        failures.push(format!("outgoing credential: {error}"));
369    }
370    if let Err(error) = restore_marker(state.marker_path, state.original_marker) {
371        failures.push(format!("identity marker: {error}"));
372    }
373    if failures.is_empty() {
374        Ok(())
375    } else {
376        Err(AppError::Other(failures.join("; ")))
377    }
378}
379
380fn restore_default(store: &dyn CredentialStore, original: Option<&str>) -> Result<()> {
381    match original {
382        Some(blob) => store.write_default(blob),
383        None => store.delete_default(),
384    }
385}
386
387fn restore_named(store: &dyn CredentialStore, path: &Path, original: Option<&str>) -> Result<()> {
388    match original {
389        Some(blob) => store.write_named(path, blob),
390        None => store.delete_named(path),
391    }
392}
393
394fn restore_marker(path: &Path, original: Option<&[u8]>) -> Result<()> {
395    if let Some(bytes) = original {
396        crate::cache::atomic_write(path, bytes)
397    } else {
398        match std::fs::remove_file(path) {
399            Ok(()) => Ok(()),
400            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
401            Err(error) => Err(AppError::io_at(path, error)),
402        }
403    }
404}
405
406fn read_optional(path: &Path) -> Result<Option<Vec<u8>>> {
407    match std::fs::read(path) {
408        Ok(bytes) => Ok(Some(bytes)),
409        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
410        Err(error) => Err(AppError::io_at(path, error)),
411    }
412}
413
414fn with_rollback(error: AppError, rollback: Result<()>) -> AppError {
415    match rollback {
416        Ok(()) => error,
417        Err(rollback) => AppError::Other(format!(
418            "{error}; automatic rollback was incomplete: {rollback}"
419        )),
420    }
421}
422
423fn oauth_account_in(claude_json: &Path) -> Option<Value> {
424    let bytes = std::fs::read(claude_json).ok()?;
425    let document: Value = serde_json::from_slice(&bytes).ok()?;
426    document
427        .get("oauthAccount")
428        .filter(|v| v.is_object())
429        .cloned()
430}
431
432#[cfg(not(target_os = "macos"))]
433fn unsupported() -> AppError {
434    AppError::Credentials(
435        "switching the `claude` CLI login is supported on macOS only (elsewhere, run \
436         `CLAUDE_CONFIG_DIR=<account dir> claude` to use a specific account)"
437            .into(),
438    )
439}
440
441impl CredentialStore for KeychainStore {
442    fn read_default(&self) -> Result<Option<String>> {
443        #[cfg(target_os = "macos")]
444        return super::keychain::read_raw();
445        #[cfg(not(target_os = "macos"))]
446        Err(unsupported())
447    }
448
449    fn write_default(&self, blob: &str) -> Result<()> {
450        #[cfg(target_os = "macos")]
451        return super::keychain::write_raw(blob);
452        #[cfg(not(target_os = "macos"))]
453        {
454            let _ = blob;
455            Err(unsupported())
456        }
457    }
458
459    fn delete_default(&self) -> Result<()> {
460        #[cfg(target_os = "macos")]
461        return super::keychain::delete_raw();
462        #[cfg(not(target_os = "macos"))]
463        Err(unsupported())
464    }
465
466    fn read_named(&self, config_dir: &Path) -> Result<Option<String>> {
467        #[cfg(target_os = "macos")]
468        return super::keychain::read_raw_for(config_dir);
469        #[cfg(not(target_os = "macos"))]
470        {
471            let _ = config_dir;
472            Err(unsupported())
473        }
474    }
475
476    fn write_named(&self, config_dir: &Path, blob: &str) -> Result<()> {
477        #[cfg(target_os = "macos")]
478        return super::keychain::write_raw_for(config_dir, blob);
479        #[cfg(not(target_os = "macos"))]
480        {
481            let _ = (config_dir, blob);
482            Err(unsupported())
483        }
484    }
485
486    fn delete_named(&self, config_dir: &Path) -> Result<()> {
487        #[cfg(target_os = "macos")]
488        return super::keychain::delete_raw_for(config_dir);
489        #[cfg(not(target_os = "macos"))]
490        {
491            let _ = config_dir;
492            Err(unsupported())
493        }
494    }
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500    use std::cell::RefCell;
501    use std::collections::BTreeMap;
502    use std::path::PathBuf;
503
504    #[derive(Default)]
505    struct FakeStore {
506        default: RefCell<Option<String>>,
507        named: RefCell<BTreeMap<PathBuf, String>>,
508        fail_delete_named: RefCell<Option<PathBuf>>,
509    }
510
511    impl CredentialStore for FakeStore {
512        fn read_default(&self) -> Result<Option<String>> {
513            Ok(self.default.borrow().clone())
514        }
515        fn write_default(&self, blob: &str) -> Result<()> {
516            *self.default.borrow_mut() = Some(blob.to_string());
517            Ok(())
518        }
519        fn delete_default(&self) -> Result<()> {
520            *self.default.borrow_mut() = None;
521            Ok(())
522        }
523        fn read_named(&self, config_dir: &Path) -> Result<Option<String>> {
524            Ok(self.named.borrow().get(config_dir).cloned())
525        }
526        fn write_named(&self, config_dir: &Path, blob: &str) -> Result<()> {
527            self.named
528                .borrow_mut()
529                .insert(config_dir.to_path_buf(), blob.to_string());
530            Ok(())
531        }
532        fn delete_named(&self, config_dir: &Path) -> Result<()> {
533            self.named.borrow_mut().remove(config_dir);
534            if self.fail_delete_named.borrow().as_deref() == Some(config_dir) {
535                Err(AppError::Other("injected delete failure".into()))
536            } else {
537                Ok(())
538            }
539        }
540    }
541
542    fn write(path: &Path, contents: &str) {
543        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
544        std::fs::write(path, contents).unwrap();
545    }
546
547    fn marker(uuid: &str, email: &str) -> String {
548        format!(r#"{{"oauthAccount":{{"accountUuid":"{uuid}","emailAddress":"{email}"}}}}"#)
549    }
550
551    struct Fixture {
552        _root: tempfile::TempDir,
553        home: PathBuf,
554        accounts: Vec<AnthropicAccount>,
555        store: FakeStore,
556    }
557
558    /// Two accounts, `work` and `personal`; the CLI is signed into `personal`.
559    fn fixture() -> Fixture {
560        let root = tempfile::TempDir::new().unwrap();
561        let home = root.path().join("home").join(CLAUDE_JSON);
562        write(&home, &marker("uuid-personal", "me@personal.test"));
563
564        let accounts: Vec<AnthropicAccount> = ["work", "personal"]
565            .iter()
566            .map(|label| AnthropicAccount {
567                label: (*label).to_string(),
568                credentials_path: root
569                    .path()
570                    .join("accounts")
571                    .join(label)
572                    .join(".credentials.json"),
573            })
574            .collect();
575        write(
576            &accounts[0].config_dir().join(CLAUDE_JSON),
577            &marker("uuid-work", "me@work.test"),
578        );
579        write(
580            &accounts[1].config_dir().join(CLAUDE_JSON),
581            &marker("uuid-personal", "me@personal.test"),
582        );
583
584        let store = FakeStore::default();
585        *store.default.borrow_mut() = Some("personal-live".into());
586        store
587            .named
588            .borrow_mut()
589            .insert(accounts[0].config_dir(), "work-saved".into());
590        store
591            .named
592            .borrow_mut()
593            .insert(accounts[1].config_dir(), "personal-stale".into());
594
595        Fixture {
596            _root: root,
597            home,
598            accounts,
599            store,
600        }
601    }
602
603    #[test]
604    fn the_live_login_resolves_to_its_label() {
605        let f = fixture();
606        assert_eq!(
607            resolve_active_label(&f.home, &f.accounts).as_deref(),
608            Some("personal")
609        );
610        assert_eq!(
611            account_email_in(&f.home).as_deref(),
612            Some("me@personal.test")
613        );
614    }
615
616    #[test]
617    fn adopting_marks_the_live_login_and_leaves_the_credential_alone() {
618        let f = fixture();
619        write(&f.home, &marker("uuid-main", "me@main.test"));
620        let main = AnthropicAccount {
621            label: "main".into(),
622            credentials_path: f
623                .home
624                .parent()
625                .unwrap()
626                .join("main")
627                .join(".credentials.json"),
628        };
629        let mut accounts = f.accounts.clone();
630        accounts.push(main.clone());
631        adopt_current(&f.home, &accounts, &main, &f.store).unwrap();
632        assert_eq!(
633            resolve_active_label(&f.home, &accounts).as_deref(),
634            Some("main")
635        );
636        assert_eq!(f.store.read_named(&main.config_dir()).unwrap(), None);
637        assert_eq!(
638            f.store.read_default().unwrap().as_deref(),
639            Some("personal-live")
640        );
641    }
642
643    #[test]
644    fn adopting_refuses_a_login_another_account_owns() {
645        let f = fixture();
646        let main = AnthropicAccount {
647            label: "main".into(),
648            credentials_path: f
649                .home
650                .parent()
651                .unwrap()
652                .join("main")
653                .join(".credentials.json"),
654        };
655        let error = adopt_current(&f.home, &f.accounts, &main, &f.store).unwrap_err();
656        assert!(error.to_string().contains("\"personal\""), "{error}");
657    }
658
659    #[test]
660    fn adopting_refuses_an_account_with_its_own_login() {
661        let f = fixture();
662        write(&f.home, &marker("uuid-main", "me@main.test"));
663        let error = adopt_current(&f.home, &f.accounts, &f.accounts[0], &f.store).unwrap_err();
664        assert!(error.to_string().contains("two copies"), "{error}");
665    }
666
667    #[test]
668    fn an_unmanaged_login_resolves_to_nothing() {
669        let f = fixture();
670        write(&f.home, &marker("uuid-stranger", "who@example.test"));
671        assert_eq!(resolve_active_label(&f.home, &f.accounts), None);
672    }
673
674    #[test]
675    fn a_missing_marker_file_is_not_an_error() {
676        let f = fixture();
677        std::fs::remove_file(&f.home).unwrap();
678        assert_eq!(resolve_active_label(&f.home, &f.accounts), None);
679        assert_eq!(account_uuid_in(&f.home), None);
680    }
681
682    #[test]
683    fn switching_captures_the_outgoing_credential_first() {
684        let f = fixture();
685
686        let outcome = switch_cli_account(
687            &f.home,
688            &f.accounts,
689            "work",
690            CliSwitchOpts::default(),
691            &f.store,
692        )
693        .unwrap();
694
695        assert_eq!(
696            outcome,
697            CliSwitchOutcome::Switched {
698                outgoing: Some("personal".into())
699            }
700        );
701        // The freshest lineage was written back into personal's own slot before
702        // the default slot was overwritten.
703        assert_eq!(
704            f.store.named.borrow()[&f.accounts[1].config_dir()],
705            "personal-live"
706        );
707        assert_eq!(f.store.default.borrow().as_deref(), Some("work-saved"));
708        assert!(
709            !f.store
710                .named
711                .borrow()
712                .contains_key(&f.accounts[0].config_dir()),
713            "the active credential must be moved, not left as a rotating-token copy"
714        );
715        assert_eq!(
716            resolve_active_label(&f.home, &f.accounts).as_deref(),
717            Some("work")
718        );
719    }
720
721    #[test]
722    fn switching_is_idempotent() {
723        let f = fixture();
724        let outcome = switch_cli_account(
725            &f.home,
726            &f.accounts,
727            "personal",
728            CliSwitchOpts::default(),
729            &f.store,
730        )
731        .unwrap();
732        assert_eq!(outcome, CliSwitchOutcome::RemovedDuplicate);
733        assert_eq!(f.store.default.borrow().as_deref(), Some("personal-live"));
734        assert!(
735            !f.store
736                .named
737                .borrow()
738                .contains_key(&f.accounts[1].config_dir())
739        );
740
741        let second = switch_cli_account(
742            &f.home,
743            &f.accounts,
744            "personal",
745            CliSwitchOpts::default(),
746            &f.store,
747        )
748        .unwrap();
749        assert_eq!(second, CliSwitchOutcome::AlreadyActive);
750    }
751
752    #[test]
753    fn an_active_marker_with_an_empty_default_slot_is_repaired() {
754        let f = fixture();
755        *f.store.default.borrow_mut() = None;
756
757        let outcome = switch_cli_account(
758            &f.home,
759            &f.accounts,
760            "personal",
761            CliSwitchOpts::default(),
762            &f.store,
763        )
764        .unwrap();
765
766        assert_eq!(outcome, CliSwitchOutcome::RepairedActive);
767        assert_eq!(f.store.default.borrow().as_deref(), Some("personal-stale"));
768        assert!(
769            !f.store
770                .named
771                .borrow()
772                .contains_key(&f.accounts[1].config_dir())
773        );
774    }
775
776    #[test]
777    fn a_dry_run_validates_without_writing() {
778        let f = fixture();
779        let before = std::fs::read(&f.home).unwrap();
780
781        let outcome = switch_cli_account(
782            &f.home,
783            &f.accounts,
784            "work",
785            CliSwitchOpts {
786                dry_run: true,
787                ..CliSwitchOpts::default()
788            },
789            &f.store,
790        )
791        .unwrap();
792
793        assert_eq!(
794            outcome,
795            CliSwitchOutcome::WouldSwitch {
796                outgoing: Some("personal".into())
797            }
798        );
799        assert_eq!(f.store.default.borrow().as_deref(), Some("personal-live"));
800        assert_eq!(std::fs::read(&f.home).unwrap(), before);
801    }
802
803    #[test]
804    fn an_unmanaged_live_login_is_refused_without_force() {
805        let f = fixture();
806        write(&f.home, &marker("uuid-stranger", "who@example.test"));
807
808        let error = switch_cli_account(
809            &f.home,
810            &f.accounts,
811            "work",
812            CliSwitchOpts::default(),
813            &f.store,
814        )
815        .unwrap_err();
816        assert!(error.to_string().contains("--force"), "{error}");
817        assert_eq!(f.store.default.borrow().as_deref(), Some("personal-live"));
818
819        switch_cli_account(
820            &f.home,
821            &f.accounts,
822            "work",
823            CliSwitchOpts {
824                force: true,
825                ..CliSwitchOpts::default()
826            },
827            &f.store,
828        )
829        .unwrap();
830        assert_eq!(f.store.default.borrow().as_deref(), Some("work-saved"));
831        assert!(
832            !f.store
833                .named
834                .borrow()
835                .contains_key(&f.accounts[0].config_dir())
836        );
837    }
838
839    #[test]
840    fn an_empty_default_slot_does_not_require_force() {
841        let f = fixture();
842        *f.store.default.borrow_mut() = None;
843        std::fs::remove_file(&f.home).unwrap();
844
845        switch_cli_account(
846            &f.home,
847            &f.accounts,
848            "work",
849            CliSwitchOpts::default(),
850            &f.store,
851        )
852        .unwrap();
853
854        assert_eq!(f.store.default.borrow().as_deref(), Some("work-saved"));
855        assert_eq!(
856            resolve_active_label(&f.home, &f.accounts).as_deref(),
857            Some("work")
858        );
859    }
860
861    #[test]
862    fn a_target_without_an_identity_marker_changes_nothing() {
863        let f = fixture();
864        std::fs::remove_file(f.accounts[0].config_dir().join(CLAUDE_JSON)).unwrap();
865        let before = std::fs::read(&f.home).unwrap();
866
867        let error = switch_cli_account(
868            &f.home,
869            &f.accounts,
870            "work",
871            CliSwitchOpts::default(),
872            &f.store,
873        )
874        .unwrap_err();
875
876        assert!(error.to_string().contains("identity marker"), "{error}");
877        assert_eq!(f.store.default.borrow().as_deref(), Some("personal-live"));
878        assert_eq!(std::fs::read(&f.home).unwrap(), before);
879    }
880
881    #[test]
882    fn a_late_failure_restores_every_credential_slot_and_marker() {
883        let f = fixture();
884        let before_marker = std::fs::read(&f.home).unwrap();
885        *f.store.fail_delete_named.borrow_mut() = Some(f.accounts[0].config_dir());
886
887        let error = switch_cli_account(
888            &f.home,
889            &f.accounts,
890            "work",
891            CliSwitchOpts::default(),
892            &f.store,
893        )
894        .unwrap_err();
895
896        assert!(
897            error.to_string().contains("injected delete failure"),
898            "{error}"
899        );
900        assert_eq!(f.store.default.borrow().as_deref(), Some("personal-live"));
901        assert_eq!(
902            f.store.named.borrow()[&f.accounts[0].config_dir()],
903            "work-saved"
904        );
905        assert_eq!(
906            f.store.named.borrow()[&f.accounts[1].config_dir()],
907            "personal-stale"
908        );
909        assert_eq!(std::fs::read(&f.home).unwrap(), before_marker);
910    }
911
912    #[test]
913    fn switching_to_an_account_that_never_signed_in_changes_nothing() {
914        let f = fixture();
915        f.store
916            .named
917            .borrow_mut()
918            .remove(&f.accounts[0].config_dir());
919
920        let error = switch_cli_account(
921            &f.home,
922            &f.accounts,
923            "work",
924            CliSwitchOpts::default(),
925            &f.store,
926        )
927        .unwrap_err();
928        assert!(error.to_string().contains("account add work"), "{error}");
929        assert_eq!(f.store.default.borrow().as_deref(), Some("personal-live"));
930    }
931
932    #[test]
933    fn merging_the_marker_preserves_unrelated_state() {
934        let existing = br#"{"firstStartTime":"2026-01-01","oauthAccount":{"accountUuid":"old"},
935            "projects":{"/tmp/x":{"allowedTools":[]}}}"#;
936        let replacement = serde_json::json!({"accountUuid": "new", "emailAddress": "a@b.test"});
937
938        let bytes = merge_oauth_account(existing, Some(&replacement)).unwrap();
939        let value: Value = serde_json::from_slice(&bytes).unwrap();
940        assert_eq!(value["oauthAccount"]["accountUuid"], "new");
941        assert_eq!(value["firstStartTime"], "2026-01-01");
942        assert!(value["projects"]["/tmp/x"].is_object());
943    }
944
945    #[test]
946    fn merging_no_marker_clears_a_stale_one() {
947        let existing = br#"{"oauthAccount":{"accountUuid":"old"},"autoUpdates":true}"#;
948        let bytes = merge_oauth_account(existing, None).unwrap();
949        let value: Value = serde_json::from_slice(&bytes).unwrap();
950        assert!(value.get("oauthAccount").is_none());
951        assert_eq!(value["autoUpdates"], true);
952    }
953
954    #[test]
955    fn merging_into_an_absent_file_starts_a_fresh_document() {
956        let replacement = serde_json::json!({"accountUuid": "new"});
957        let bytes = merge_oauth_account(b"", Some(&replacement)).unwrap();
958        let value: Value = serde_json::from_slice(&bytes).unwrap();
959        assert_eq!(value["oauthAccount"]["accountUuid"], "new");
960    }
961}