Skip to main content

ai_usagebar/anthropic/
creds.rs

1//! Read and write `~/.claude/.credentials.json` — the OAuth state the Claude
2//! CLI maintains. Mirrors claudebar:330-333 (read) and claudebar:447-452 (write).
3//!
4//! On macOS the file often doesn't exist: recent Claude Code builds keep the
5//! same JSON in the login Keychain instead. For the *default* location we fall
6//! back to [`keychain`] when the file is missing or clearly unusable (#15);
7//! explicit paths (`--creds-path`, config, named accounts) are read strictly.
8
9use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12
13use crate::cache::atomic_write;
14use crate::error::{AppError, Result};
15
16#[cfg(target_os = "macos")]
17use super::keychain;
18
19/// Disk shape (matches claudebar's jq paths).
20#[derive(Debug, Clone, Deserialize, Serialize)]
21pub struct CredentialsFile {
22    #[serde(rename = "claudeAiOauth")]
23    pub claude_ai_oauth: OauthCreds,
24}
25
26#[derive(Debug, Clone, Deserialize, Serialize)]
27pub struct OauthCreds {
28    #[serde(rename = "accessToken")]
29    pub access_token: String,
30    #[serde(rename = "refreshToken")]
31    pub refresh_token: String,
32    /// Unix epoch in **milliseconds** (claudebar:445 multiplies seconds × 1000).
33    /// May arrive as a float in the wild — claudebar truncates with `%%.*`,
34    /// so we accept both.
35    #[serde(rename = "expiresAt", deserialize_with = "de_ms_epoch")]
36    pub expires_at_ms: i64,
37    #[serde(rename = "subscriptionType", default)]
38    pub subscription_type: String,
39    #[serde(rename = "rateLimitTier", default)]
40    pub rate_limit_tier: String,
41    /// Optional `scopes` array — preserved through round-trips so we don't
42    /// drop information when we write back after a refresh.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub scopes: Option<serde_json::Value>,
45}
46
47fn de_ms_epoch<'de, D>(d: D) -> std::result::Result<i64, D::Error>
48where
49    D: serde::Deserializer<'de>,
50{
51    // Accept int or float — float values like 5000.0 are truncated.
52    let v = serde_json::Value::deserialize(d)?;
53    match v {
54        serde_json::Value::Number(n) => {
55            if let Some(i) = n.as_i64() {
56                Ok(i)
57            } else if let Some(f) = n.as_f64() {
58                Ok(f as i64)
59            } else {
60                Err(serde::de::Error::custom("expiresAt not numeric"))
61            }
62        }
63        _ => Err(serde::de::Error::custom("expiresAt must be a number")),
64    }
65}
66
67impl OauthCreds {
68    /// Plan label rendered the way claudebar does (claudebar:547-550):
69    ///   "${sub_type^} [5x|20x]" (first letter capitalized, optional tier suffix).
70    pub fn plan_label(&self) -> String {
71        let mut name = crate::format::capitalize(&self.subscription_type);
72        if name.is_empty() {
73            name = "Unknown".into();
74        }
75        if self.rate_limit_tier.contains("5x") {
76            name.push_str(" 5x");
77        } else if self.rate_limit_tier.contains("20x") {
78            name.push_str(" 20x");
79        }
80        name
81    }
82
83    pub fn expires_at_secs(&self) -> i64 {
84        self.expires_at_ms / 1000
85    }
86}
87
88/// Default location: `~/.claude/.credentials.json` (Unix/macOS) or
89/// `%USERPROFILE%\.claude\.credentials.json` (Windows).
90///
91/// Home is resolved through [`crate::cache::home_dir`] so every platform's
92/// convention is honored in one place.
93pub fn default_path() -> Result<PathBuf> {
94    Ok(crate::cache::home_dir()?
95        .join(".claude")
96        .join(".credentials.json"))
97}
98
99/// Strict file read: no Keychain fallback, ever. Explicit paths (`--creds-path`,
100/// config `credentials_path`, named accounts) go through here so a missing or
101/// broken file fails loudly instead of silently reading a *different* account's
102/// credentials from the Keychain (issues #14/#15).
103pub fn read_from(path: &Path) -> Result<CredentialsFile> {
104    match std::fs::read_to_string(path) {
105        Ok(raw) => parse(&raw, &path.display().to_string()),
106        Err(e) => Err(AppError::io_at(path, e)),
107    }
108}
109
110/// Which credentials location a fetch should use. Only the platform-default
111/// location is eligible for the macOS Keychain fallback — Claude Code owns
112/// that location, so "look where Claude Code lives" includes its Keychain
113/// item. An explicit path is a user decision and is honored strictly.
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub enum CredsTarget {
116    /// `~/.claude/.credentials.json` (or the Windows equivalent) — falls back
117    /// to the macOS Keychain when the file is missing *or unusable* (#15).
118    Default(PathBuf),
119    /// `--creds-path` or config `credentials_path` with no `CLAUDE_CONFIG_DIR`
120    /// of its own — never consults the Keychain.
121    Explicit(PathBuf),
122    /// A named account (`[[anthropic.accounts]]` or `accounts_dir`): prefers
123    /// the macOS Keychain item scoped to `config_dir` (`path`'s parent, the
124    /// account's own `CLAUDE_CONFIG_DIR`) because that is where
125    /// `CLAUDE_CONFIG_DIR=<dir> claude` actually writes on macOS; `path` is
126    /// the fallback for the Linux layout and hand-managed files (see
127    /// [`read_named_with`] for the full decision table). Safe unlike a shared
128    /// fallback would be: the Keychain item is hashed from `config_dir`, so
129    /// it can never resolve to a *different* account's credentials.
130    Named { path: PathBuf, config_dir: PathBuf },
131    /// The Claude **Desktop app's** own encrypted token store — no `claude` CLI
132    /// login required. Read/decrypt/write-back all live in
133    /// [`super::desktop_creds`]; this variant just carries the resolved source.
134    Desktop(super::desktop_creds::DesktopCreds),
135}
136
137impl CredsTarget {
138    /// Filesystem path backing this target, for diagnostics. A [`Desktop`]
139    /// source has no single credential file (it decrypts a blob), so its
140    /// blob path stands in.
141    ///
142    /// [`Desktop`]: CredsTarget::Desktop
143    pub fn path(&self) -> &Path {
144        match self {
145            CredsTarget::Default(p) | CredsTarget::Explicit(p) => p,
146            CredsTarget::Named { path, .. } => path,
147            CredsTarget::Desktop(d) => d.blob_path(),
148        }
149    }
150}
151
152/// Where credentials were actually read from. Write-backs must follow this —
153/// refreshing tokens read from the Keychain into a stale file (or vice versa)
154/// would fork the credential state Claude Code depends on.
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub enum CredsSource {
157    File(PathBuf),
158    /// Only produced on macOS in production (the login Keychain item
159    /// `Claude Code-credentials`).
160    Keychain,
161    /// Only produced on macOS in production: a named account's
162    /// `CLAUDE_CONFIG_DIR`-scoped Keychain item (`Claude
163    /// Code-credentials-<hash>`), keyed by the account's config dir so
164    /// write-backs land in the same per-account item they were read from.
165    NamedKeychain(PathBuf),
166    /// The Claude Desktop token store; the handle re-encrypts a refreshed token
167    /// back into the same blob it was read from (a no-op for a read-only
168    /// active account).
169    Desktop(super::desktop_creds::Writeback),
170}
171
172/// Credentials that can't possibly authenticate anything: no access token, no
173/// refresh token, no future expiry. This is the #15 predicate — deliberately
174/// narrow so the v0.7.2 trusted-device shape (empty `refreshToken` but a live
175/// `accessToken`) keeps its file-first behavior.
176pub fn is_unusable(oauth: &OauthCreds) -> bool {
177    oauth.access_token.trim().is_empty()
178        && oauth.refresh_token.trim().is_empty()
179        && oauth.expires_at_ms <= 0
180}
181
182/// Resolve a [`CredsTarget`] to actual credentials + the source they came
183/// from. `Explicit` is a strict [`read_from`]; `Default` adds the macOS
184/// Keychain fallback.
185pub fn resolve(target: &CredsTarget) -> Result<(CredentialsFile, CredsSource)> {
186    match target {
187        CredsTarget::Explicit(p) => Ok((read_from(p)?, CredsSource::File(p.clone()))),
188        CredsTarget::Default(p) => {
189            #[cfg(target_os = "macos")]
190            return read_default_with(p, keychain::read_raw);
191            #[cfg(not(target_os = "macos"))]
192            read_default_with(p, || Ok(None))
193        }
194        CredsTarget::Named { path, config_dir } => {
195            #[cfg(target_os = "macos")]
196            return read_named_with(path, config_dir, keychain::read_raw_for);
197            #[cfg(not(target_os = "macos"))]
198            read_named_with(path, config_dir, |_| Ok(None))
199        }
200        CredsTarget::Desktop(desktop) => {
201            let (creds, writeback) = desktop.read()?;
202            Ok((creds, CredsSource::Desktop(writeback)))
203        }
204    }
205}
206
207/// Default-location read with an injectable Keychain reader, so the fallback
208/// selection is unit-testable on any platform (the hermeticity invariant —
209/// tests must never touch a real Keychain). Decision table:
210///
211/// | file state          | keychain        | outcome                    |
212/// |---------------------|-----------------|----------------------------|
213/// | usable              | (not consulted*)| file                       |
214/// | missing             | usable          | keychain                   |
215/// | missing             | absent          | original I/O error         |
216/// | unusable (#15)      | usable          | keychain                   |
217/// | unusable (#15)      | absent          | file result, unchanged     |
218/// | unparsable JSON     | usable          | keychain                   |
219/// | unparsable JSON     | absent          | original parse error       |
220/// | any                 | **unreadable**  | the Keychain error         |
221///
222/// The last row matters: a *locked* login Keychain or a denied ACL is not the
223/// same as "no credentials". Reporting it as absent surfaced a "run `claude`"
224/// message and sent users to re-authenticate while their credentials were
225/// sitting there intact, so that failure now wins over the file's own error.
226///
227/// *usable file short-circuits — no `security(1)` subprocess on the happy path.
228fn read_default_with(
229    path: &Path,
230    keychain_read: impl Fn() -> Result<Option<String>>,
231) -> Result<(CredentialsFile, CredsSource)> {
232    let file_result = read_from(path);
233    match &file_result {
234        Ok(creds) if !is_unusable(&creds.claude_ai_oauth) => {
235            Ok((file_result?, CredsSource::File(path.to_path_buf())))
236        }
237        // Missing, unusable, or unparsable — see if the Keychain has better.
238        _ => match keychain_read()? {
239            Some(raw) => match parse(&raw, "macOS Keychain (Claude Code-credentials)") {
240                Ok(kc) if !is_unusable(&kc.claude_ai_oauth) => Ok((kc, CredsSource::Keychain)),
241                // Keychain no better than the file — surface the file outcome.
242                _ => Ok((file_result?, CredsSource::File(path.to_path_buf()))),
243            },
244            None => Ok((file_result?, CredsSource::File(path.to_path_buf()))),
245        },
246    }
247}
248
249/// Named-account read: the account's `CLAUDE_CONFIG_DIR`-scoped Keychain item
250/// wins over the file. On macOS the `claude` CLI *always* writes logins to the
251/// Keychain — `CLAUDE_CONFIG_DIR=<dir> claude` lands in the dir-scoped item,
252/// never in `<dir>/.credentials.json` — so a file there is at best a hand-made
253/// snapshot. Snapshots die: their refresh-token lineage rotates away the next
254/// time the *real* holder refreshes, and the copy 401s within hours (observed
255/// in production; that failure is what motivated this order). Decision table:
256///
257/// | keychain item              | file             | outcome            |
258/// |----------------------------|------------------|--------------------|
259/// | usable                     | (not consulted)  | keychain           |
260/// | absent/unusable/unparsable | readable         | file               |
261/// | absent/unusable/unparsable | missing/broken   | the file's error   |
262/// | **unreadable** (locked/ACL)| any              | the Keychain error |
263///
264/// The unreadable row follows [`read_default_with`]'s reasoning: a locked
265/// Keychain is not "no credentials", and silently dropping to a stale file
266/// snapshot would produce exactly the confusing half-dead state this order
267/// exists to prevent. On non-macOS the injected reader returns `None`, so the
268/// file is simply read strictly — the Linux layout is file-only.
269fn read_named_with(
270    path: &Path,
271    config_dir: &Path,
272    keychain_read: impl Fn(&Path) -> Result<Option<String>>,
273) -> Result<(CredentialsFile, CredsSource)> {
274    // A present-but-unparsable/unusable item is no better than the file, so
275    // the whole chain failing falls through to the strict file read.
276    if let Some(raw) = keychain_read(config_dir)?
277        && let Ok(kc) = parse(&raw, "macOS Keychain (named account)")
278        && !is_unusable(&kc.claude_ai_oauth)
279    {
280        return Ok((kc, CredsSource::NamedKeychain(config_dir.to_path_buf())));
281    }
282    Ok((read_from(path)?, CredsSource::File(path.to_path_buf())))
283}
284
285/// Parse a credentials JSON blob from any source (`source` only labels errors).
286fn parse(raw: &str, source: &str) -> Result<CredentialsFile> {
287    serde_json::from_str(raw).map_err(|e| {
288        AppError::Credentials(format!(
289            "could not parse {source}: {e}. Run `claude` to re-authenticate."
290        ))
291    })
292}
293
294/// Merge a refreshed `claudeAiOauth` into an existing credentials document
295/// (or a fresh `{}`), preserving any unknown top-level fields the Claude CLI
296/// keeps there (e.g. `mcpOAuth`). Pure so the merge is unit-testable without
297/// touching disk or the Keychain.
298fn merge_oauth(existing: Option<&str>, new_oauth: &OauthCreds) -> Result<serde_json::Value> {
299    let mut doc: serde_json::Value = existing
300        .and_then(|s| serde_json::from_str(s).ok())
301        .unwrap_or_else(|| serde_json::json!({}));
302    if !doc.is_object() {
303        doc = serde_json::json!({});
304    }
305    doc.as_object_mut().expect("just ensured object").insert(
306        "claudeAiOauth".into(),
307        serde_json::to_value(new_oauth).map_err(AppError::Json)?,
308    );
309    Ok(doc)
310}
311
312/// Persist updated credentials to the source they were actually read from
313/// (see [`CredsSource`]), preserving any unknown top-level fields the Claude
314/// CLI might have added. Following the read source keeps a single shared
315/// source of truth with Claude Code — refreshing Keychain-read tokens into a
316/// stale shadow file would rotate the refresh token out from under it.
317pub fn write_back_to(source: &CredsSource, new_oauth: &OauthCreds) -> Result<()> {
318    match source {
319        CredsSource::File(path) => write_back(path, new_oauth),
320        #[cfg(target_os = "macos")]
321        CredsSource::Keychain => {
322            let existing = keychain::read_raw()?;
323            let doc = merge_oauth(existing.as_deref(), new_oauth)?;
324            let json = serde_json::to_string(&doc).map_err(AppError::Json)?;
325            keychain::write_raw(&json)
326        }
327        #[cfg(not(target_os = "macos"))]
328        CredsSource::Keychain => Err(AppError::Other(
329            "Keychain credentials source is macOS-only".into(),
330        )),
331        #[cfg(target_os = "macos")]
332        CredsSource::NamedKeychain(config_dir) => {
333            let existing = keychain::read_raw_for(config_dir)?;
334            let doc = merge_oauth(existing.as_deref(), new_oauth)?;
335            let json = serde_json::to_string(&doc).map_err(AppError::Json)?;
336            keychain::write_raw_for(config_dir, &json)
337        }
338        #[cfg(not(target_os = "macos"))]
339        CredsSource::NamedKeychain(_) => Err(AppError::Other(
340            "Keychain credentials source is macOS-only".into(),
341        )),
342        CredsSource::Desktop(writeback) => writeback.write(new_oauth),
343    }
344}
345
346/// Persist updated credentials to a file, preserving unknown top-level fields.
347pub fn write_back(path: &Path, new_oauth: &OauthCreds) -> Result<()> {
348    let existing = std::fs::read_to_string(path).ok();
349    let doc = merge_oauth(existing.as_deref(), new_oauth)?;
350    let bytes = serde_json::to_vec_pretty(&doc).map_err(AppError::Json)?;
351    atomic_write(path, &bytes)
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use std::io::Write;
358    use tempfile::{NamedTempFile, TempDir};
359
360    fn write_creds(s: &str) -> NamedTempFile {
361        let mut f = NamedTempFile::new().unwrap();
362        f.write_all(s.as_bytes()).unwrap();
363        f.flush().unwrap();
364        f
365    }
366
367    /// Like `write_creds`, but with no open handle on the file, so
368    /// `write_back`'s atomic rename-over-destination succeeds on Windows.
369    /// See [`crate::cache::closed_temp_file`].
370    fn write_creds_closed(s: &str) -> (TempDir, std::path::PathBuf) {
371        crate::cache::closed_temp_file("credentials.json", Some(s))
372    }
373
374    #[test]
375    fn parses_canonical_shape() {
376        let f = write_creds(
377            r#"{"claudeAiOauth":{
378                "accessToken":"AT",
379                "refreshToken":"RT",
380                "expiresAt": 1735000000000,
381                "subscriptionType":"max",
382                "rateLimitTier":"default_claude_max_5x"
383            }}"#,
384        );
385        let creds = read_from(f.path()).unwrap();
386        assert_eq!(creds.claude_ai_oauth.access_token, "AT");
387        assert_eq!(creds.claude_ai_oauth.expires_at_ms, 1735000000000);
388        assert_eq!(creds.claude_ai_oauth.plan_label(), "Max 5x");
389    }
390
391    #[test]
392    fn accepts_float_expires_at() {
393        // claudebar truncates `5000.0 → 5000`; we do the same.
394        let f = write_creds(
395            r#"{"claudeAiOauth":{
396                "accessToken":"A","refreshToken":"R",
397                "expiresAt": 5000.0,
398                "subscriptionType":"pro","rateLimitTier":""
399            }}"#,
400        );
401        let creds = read_from(f.path()).unwrap();
402        assert_eq!(creds.claude_ai_oauth.expires_at_ms, 5000);
403    }
404
405    #[test]
406    fn plan_label_pro_no_tier() {
407        let f = write_creds(
408            r#"{"claudeAiOauth":{
409                "accessToken":"A","refreshToken":"R","expiresAt": 0,
410                "subscriptionType":"pro","rateLimitTier":""
411            }}"#,
412        );
413        let creds = read_from(f.path()).unwrap();
414        assert_eq!(creds.claude_ai_oauth.plan_label(), "Pro");
415    }
416
417    #[test]
418    fn plan_label_max_20x() {
419        let f = write_creds(
420            r#"{"claudeAiOauth":{
421                "accessToken":"A","refreshToken":"R","expiresAt": 0,
422                "subscriptionType":"max","rateLimitTier":"default_claude_max_20x"
423            }}"#,
424        );
425        let creds = read_from(f.path()).unwrap();
426        assert_eq!(creds.claude_ai_oauth.plan_label(), "Max 20x");
427    }
428
429    #[test]
430    fn plan_label_empty_subscription_falls_back() {
431        let f = write_creds(
432            r#"{"claudeAiOauth":{
433                "accessToken":"A","refreshToken":"R","expiresAt": 0,
434                "subscriptionType":"","rateLimitTier":""
435            }}"#,
436        );
437        let creds = read_from(f.path()).unwrap();
438        assert_eq!(creds.claude_ai_oauth.plan_label(), "Unknown");
439    }
440
441    #[test]
442    fn malformed_file_returns_credentials_error() {
443        let f = write_creds("not json");
444        let err = read_from(f.path()).unwrap_err();
445        assert!(matches!(err, AppError::Credentials(_)));
446    }
447
448    // Linux-only: a missing file with no Keychain fallback is an I/O error,
449    // not a parse error. `read_from` is strict on every platform — explicit
450    // paths never consult the Keychain (issues #14/#15) — so this needs no
451    // macOS gate anymore.
452    #[test]
453    fn read_from_missing_file_is_io_error() {
454        let path = std::path::Path::new("/nonexistent/ai-usagebar/.credentials.json");
455        let err = read_from(path).unwrap_err();
456        assert!(matches!(err, AppError::Io { .. }));
457    }
458
459    // --- issue #15: default-location Keychain fallback selection ------------
460    // `read_default_with` takes the keychain reader as a closure, so these run
461    // hermetically on any platform — no real Keychain, no real $HOME.
462
463    const USABLE: &str = r#"{"claudeAiOauth":{
464        "accessToken":"live-token","refreshToken":"rt","expiresAt": 9999999999999,
465        "subscriptionType":"max","rateLimitTier":""}}"#;
466    const UNUSABLE: &str = r#"{"claudeAiOauth":{
467        "accessToken":"","refreshToken":"","expiresAt": 0,
468        "subscriptionType":"","rateLimitTier":""}}"#;
469    const KEYCHAIN_USABLE: &str = r#"{"claudeAiOauth":{
470        "accessToken":"kc-token","refreshToken":"kc-rt","expiresAt": 9999999999999,
471        "subscriptionType":"max","rateLimitTier":""}}"#;
472
473    #[test]
474    fn is_unusable_only_when_fully_dead() {
475        let dead: CredentialsFile = serde_json::from_str(UNUSABLE).unwrap();
476        assert!(is_unusable(&dead.claude_ai_oauth));
477        // The v0.7.2 trusted-device shape (empty refreshToken, live
478        // accessToken) must NOT count as unusable — file stays authoritative.
479        let trusted: CredentialsFile = serde_json::from_str(
480            r#"{"claudeAiOauth":{"accessToken":"live","refreshToken":"",
481                "expiresAt": 9999999999999,"subscriptionType":"max","rateLimitTier":""}}"#,
482        )
483        .unwrap();
484        assert!(!is_unusable(&trusted.claude_ai_oauth));
485    }
486
487    #[test]
488    fn default_read_usable_file_wins_without_consulting_keychain() {
489        let (_dir, path) = write_creds_closed(USABLE);
490        let (creds, source) =
491            read_default_with(&path, || panic!("keychain must not be consulted")).unwrap();
492        assert_eq!(creds.claude_ai_oauth.access_token, "live-token");
493        assert_eq!(source, CredsSource::File(path));
494    }
495
496    #[test]
497    fn default_read_missing_file_falls_back_to_keychain() {
498        let dir = TempDir::new().unwrap();
499        let path = dir.path().join("missing.json");
500        let (creds, source) =
501            read_default_with(&path, || Ok(Some(KEYCHAIN_USABLE.into()))).unwrap();
502        assert_eq!(creds.claude_ai_oauth.access_token, "kc-token");
503        assert_eq!(source, CredsSource::Keychain);
504    }
505
506    #[test]
507    fn default_read_missing_file_without_keychain_is_io_error() {
508        let dir = TempDir::new().unwrap();
509        let path = dir.path().join("missing.json");
510        let err = read_default_with(&path, || Ok(None)).unwrap_err();
511        assert!(matches!(err, AppError::Io { .. }));
512    }
513
514    #[test]
515    fn a_locked_keychain_wins_over_the_file_missing_error() {
516        // The regression this guards: `read_raw` mapped *every* `security`
517        // failure to Ok(None), so a locked login Keychain looked identical to
518        // "not logged in" and the user was told to run `claude` while their
519        // credentials sat there intact.
520        let dir = TempDir::new().unwrap();
521        let path = dir.path().join("missing.json");
522        let err = read_default_with(&path, || {
523            Err(AppError::Credentials("the Keychain is locked".into()))
524        })
525        .unwrap_err();
526        assert!(
527            matches!(err, AppError::Credentials(ref m) if m.contains("locked")),
528            "expected the Keychain error to surface, got {err:?}"
529        );
530    }
531
532    #[test]
533    fn default_read_unusable_file_prefers_usable_keychain() {
534        // The #15 scenario: stale zeroed file shadowing fresh Keychain creds.
535        let (_dir, path) = write_creds_closed(UNUSABLE);
536        let (creds, source) =
537            read_default_with(&path, || Ok(Some(KEYCHAIN_USABLE.into()))).unwrap();
538        assert_eq!(creds.claude_ai_oauth.access_token, "kc-token");
539        assert_eq!(source, CredsSource::Keychain);
540    }
541
542    #[test]
543    fn default_read_unusable_file_kept_when_keychain_absent_or_dead() {
544        let (_dir, path) = write_creds_closed(UNUSABLE);
545        // No keychain item → the file result stands, source stays File.
546        let (creds, source) = read_default_with(&path, || Ok(None)).unwrap();
547        assert!(is_unusable(&creds.claude_ai_oauth));
548        assert_eq!(source, CredsSource::File(path.clone()));
549        // Keychain item just as dead → same.
550        let (_, source) = read_default_with(&path, || Ok(Some(UNUSABLE.into()))).unwrap();
551        assert_eq!(source, CredsSource::File(path));
552    }
553
554    #[test]
555    fn default_read_unparsable_file_falls_back_to_keychain_else_errors() {
556        let (_dir, path) = write_creds_closed("not json at all");
557        let (creds, source) =
558            read_default_with(&path, || Ok(Some(KEYCHAIN_USABLE.into()))).unwrap();
559        assert_eq!(creds.claude_ai_oauth.access_token, "kc-token");
560        assert_eq!(source, CredsSource::Keychain);
561        // Without a keychain rescue, the original parse error surfaces.
562        let err = read_default_with(&path, || Ok(None)).unwrap_err();
563        assert!(matches!(err, AppError::Credentials(_)));
564    }
565
566    // --- named accounts: config-dir-scoped Keychain wins over the file ------
567    // `read_named_with` takes the keychain reader as a closure, so these run
568    // hermetically on any platform — no real Keychain, no real $HOME.
569
570    #[test]
571    fn named_read_prefers_keychain_over_a_live_looking_file() {
572        // The production failure this order exists for: the file is a stale
573        // hand-made snapshot that still *looks* usable (tokens present) but
574        // whose refresh lineage has rotated away; the scoped Keychain item is
575        // what `claude` actually keeps alive.
576        let (_dir, path) = write_creds_closed(USABLE);
577        let cfg_dir = path.parent().unwrap().to_path_buf();
578        let (creds, source) =
579            read_named_with(&path, &cfg_dir, |_| Ok(Some(KEYCHAIN_USABLE.into()))).unwrap();
580        assert_eq!(creds.claude_ai_oauth.access_token, "kc-token");
581        assert_eq!(source, CredsSource::NamedKeychain(cfg_dir));
582    }
583
584    #[test]
585    fn named_read_falls_back_to_file_when_keychain_absent() {
586        // Linux layout / hand-managed file: no scoped Keychain item exists.
587        let (_dir, path) = write_creds_closed(USABLE);
588        let cfg_dir = path.parent().unwrap().to_path_buf();
589        let (creds, source) = read_named_with(&path, &cfg_dir, |_| Ok(None)).unwrap();
590        assert_eq!(creds.claude_ai_oauth.access_token, "live-token");
591        assert_eq!(source, CredsSource::File(path));
592    }
593
594    #[test]
595    fn named_read_unusable_keychain_falls_back_to_file() {
596        let (_dir, path) = write_creds_closed(USABLE);
597        let cfg_dir = path.parent().unwrap().to_path_buf();
598        let (_, source) = read_named_with(&path, &cfg_dir, |_| Ok(Some(UNUSABLE.into()))).unwrap();
599        assert_eq!(source, CredsSource::File(path.clone()));
600        // Unparsable Keychain blob — same fallback.
601        let (_, source) =
602            read_named_with(&path, &cfg_dir, |_| Ok(Some("not json".into()))).unwrap();
603        assert_eq!(source, CredsSource::File(path));
604    }
605
606    #[test]
607    fn named_read_both_missing_surfaces_the_file_error() {
608        let dir = TempDir::new().unwrap();
609        let path = dir.path().join("missing.json");
610        let err = read_named_with(&path, dir.path(), |_| Ok(None)).unwrap_err();
611        assert!(matches!(err, AppError::Io { .. }));
612    }
613
614    #[test]
615    fn named_read_locked_keychain_error_surfaces_not_the_stale_file() {
616        // Same reasoning as the default account: a locked Keychain is not
617        // "no credentials", and silently reading a stale snapshot instead
618        // recreates exactly the half-dead fork this order prevents.
619        let (_dir, path) = write_creds_closed(USABLE);
620        let cfg_dir = path.parent().unwrap();
621        let err = read_named_with(&path, cfg_dir, |_| {
622            Err(AppError::Credentials("the Keychain is locked".into()))
623        })
624        .unwrap_err();
625        assert!(matches!(err, AppError::Credentials(ref m) if m.contains("locked")));
626    }
627
628    #[test]
629    fn resolve_explicit_never_falls_back() {
630        // An explicit target with a missing file is an I/O error even on a
631        // machine whose Keychain holds valid creds — resolve() routes Explicit
632        // through the strict read_from, which has no keychain path at all.
633        let dir = TempDir::new().unwrap();
634        let target = CredsTarget::Explicit(dir.path().join("missing.json"));
635        assert!(matches!(resolve(&target).unwrap_err(), AppError::Io { .. }));
636    }
637
638    #[test]
639    fn default_path_ends_with_claude_credentials() {
640        let p = default_path().unwrap();
641        // The trailing two segments are stable across platforms; only the home
642        // prefix differs (resolved by directories::BaseDirs).
643        assert!(p.ends_with(std::path::Path::new(".claude").join(".credentials.json")));
644    }
645
646    // On Windows the home prefix is %USERPROFILE%, not $HOME — assert the
647    // resolver honors it so the credential file is found natively.
648    #[cfg(windows)]
649    #[test]
650    fn default_path_uses_userprofile_on_windows() {
651        let p = default_path().unwrap();
652        let userprofile = std::env::var("USERPROFILE").expect("USERPROFILE set on Windows");
653        // directories::BaseDirs resolves the home via SHGetKnownFolderPath, which
654        // can differ from %USERPROFILE% in casing or path separator. Compare on a
655        // normalized basis (lowercased, backslashes) rather than Path::starts_with,
656        // which compares components case-sensitively even on Windows.
657        let norm = |s: &str| s.to_lowercase().replace('/', "\\");
658        let p_norm = norm(&p.to_string_lossy());
659        let up_norm = norm(&userprofile);
660        assert!(
661            p_norm.starts_with(up_norm.as_str()),
662            "{} should live under {}",
663            p.display(),
664            userprofile
665        );
666    }
667
668    #[test]
669    fn merge_oauth_preserves_unknown_top_level_fields() {
670        let existing = r#"{"claudeAiOauth":{"accessToken":"OLD"},"mcpOAuth":{"x":1}}"#;
671        let new_oauth = OauthCreds {
672            access_token: "NEW".into(),
673            refresh_token: "RT".into(),
674            expires_at_ms: 99,
675            subscription_type: "max".into(),
676            rate_limit_tier: "".into(),
677            scopes: None,
678        };
679        let doc = merge_oauth(Some(existing), &new_oauth).unwrap();
680        assert_eq!(doc["mcpOAuth"]["x"], 1);
681        assert_eq!(doc["claudeAiOauth"]["accessToken"], "NEW");
682        assert_eq!(doc["claudeAiOauth"]["expiresAt"], 99);
683    }
684
685    #[test]
686    fn merge_oauth_handles_empty_and_non_object_input() {
687        let new_oauth = OauthCreds {
688            access_token: "A".into(),
689            refresh_token: "R".into(),
690            expires_at_ms: 0,
691            subscription_type: "pro".into(),
692            rate_limit_tier: "".into(),
693            scopes: None,
694        };
695        // None → fresh object.
696        let doc = merge_oauth(None, &new_oauth).unwrap();
697        assert_eq!(doc["claudeAiOauth"]["accessToken"], "A");
698        // Garbage / non-object → discarded, fresh object.
699        let doc = merge_oauth(Some("not json"), &new_oauth).unwrap();
700        assert_eq!(doc["claudeAiOauth"]["accessToken"], "A");
701        let doc = merge_oauth(Some("[1,2,3]"), &new_oauth).unwrap();
702        assert_eq!(doc["claudeAiOauth"]["accessToken"], "A");
703    }
704
705    #[test]
706    fn write_back_round_trips_and_preserves_unknown_fields() {
707        let (_dir, path) = write_creds_closed(
708            r#"{"claudeAiOauth":{
709                "accessToken":"OLD","refreshToken":"OLD","expiresAt": 0,
710                "subscriptionType":"pro","rateLimitTier":""
711            },"someOtherField":"keep me"}"#,
712        );
713        let creds = read_from(&path).unwrap();
714        let new_oauth = OauthCreds {
715            access_token: "NEW".into(),
716            refresh_token: "NEW_RT".into(),
717            expires_at_ms: 1234,
718            subscription_type: "pro".into(),
719            rate_limit_tier: "".into(),
720            scopes: creds.claude_ai_oauth.scopes.clone(),
721        };
722        write_back(&path, &new_oauth).unwrap();
723        // Re-read & verify the unknown field survived.
724        let raw = std::fs::read_to_string(&path).unwrap();
725        let v: serde_json::Value = serde_json::from_str(&raw).unwrap();
726        assert_eq!(v["someOtherField"], "keep me");
727        assert_eq!(v["claudeAiOauth"]["accessToken"], "NEW");
728        assert_eq!(v["claudeAiOauth"]["expiresAt"], 1234);
729    }
730}