clauth 0.3.2

Simple Claude Code account switcher and usage monitor
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};

use crate::lock::with_state_lock;
use crate::profile::{
    AppConfig, ClaudeCredentials, Profile, atomic_write, home_dir, profile_dir, save_profile,
};

fn claude_credentials_path() -> Result<PathBuf> {
    Ok(home_dir()?.join(".claude").join(".credentials.json"))
}

fn claude_settings_path() -> Result<PathBuf> {
    Ok(home_dir()?.join(".claude").join("settings.json"))
}

/// State of `~/.claude/.credentials.json` relative to a profile's stored
/// credentials. Lets callers refuse to corrupt the profile when the live
/// path is no longer the symlink clauth installed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LinkState {
    /// Symlink in place and resolving to the profile's stored credentials.
    LinkedTo,
    /// Live path exists but is not our symlink — CC re-logged via
    /// unlink+write, the user edited it by hand, or a stale post-shutdown
    /// copy is sitting there.
    Diverged,
    /// Live path does not exist.
    Missing,
}

pub(crate) fn classify_credentials_link(active: &str) -> Result<LinkState> {
    let link = claude_credentials_path()?;
    let expected = profile_dir(active)?.join("credentials.json");
    classify_link_at(&link, &expected)
}

/// Pure path classifier used by `classify_credentials_link` and the inline
/// tests. Symlink target comparison is canonical-when-possible, falling back
/// to literal path equality when either side does not currently resolve.
pub(crate) fn classify_link_at(link: &Path, expected: &Path) -> Result<LinkState> {
    let meta = match link.symlink_metadata() {
        Ok(m) => m,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(LinkState::Missing),
        Err(e) => return Err(e).context("Failed to stat .credentials.json"),
    };
    if !meta.file_type().is_symlink() {
        return Ok(LinkState::Diverged);
    }
    let target = std::fs::read_link(link).context("Failed to read .credentials.json link")?;
    if paths_equivalent(&target, expected) {
        Ok(LinkState::LinkedTo)
    } else {
        Ok(LinkState::Diverged)
    }
}

fn paths_equivalent(a: &Path, b: &Path) -> bool {
    match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
        (Ok(a), Ok(b)) => a == b,
        _ => a == b,
    }
}

/// True when `active` owns no stored credentials yet and the live
/// `.credentials.json` is a regular file carrying a completed OAuth login.
/// This is a credential-less profile's first login (e.g. a blank profile the
/// user just authenticated through Claude Code), which clauth adopts rather
/// than refusing as divergence. Mirrors the runtime watchdog's first-login
/// handling in `sync_credentials_unlocked`.
pub(crate) fn is_first_login(active: &str) -> Result<bool> {
    let link = claude_credentials_path()?;
    let expected = profile_dir(active)?.join("credentials.json");
    Ok(is_first_login_at(&link, &expected))
}

/// Pure path-based companion to [`is_first_login`], split out for testing.
/// `expected` is the profile's stored credentials file; its absence is the
/// "no stored credentials" signal. The OAuth check rejects a mid-flight
/// partial write (e.g. an empty `{}`) so adoption waits for a completed login.
fn is_first_login_at(link: &Path, expected: &Path) -> bool {
    if expected.exists() {
        return false;
    }
    let Ok(meta) = link.symlink_metadata() else {
        return false;
    };
    if meta.file_type().is_symlink() {
        return false;
    }
    std::fs::read(link)
        .ok()
        .and_then(|bytes| serde_json::from_slice::<ClaudeCredentials>(&bytes).ok())
        .is_some_and(|creds| creds.claude_ai_oauth.is_some())
}

pub(crate) fn read_claude_credentials() -> Result<Option<ClaudeCredentials>> {
    let path = claude_credentials_path()?;
    if !path.exists() {
        return Ok(None);
    }
    let content = std::fs::read_to_string(&path).context("Failed to read .credentials.json")?;
    serde_json::from_str(&content)
        .context("Failed to parse .credentials.json")
        .map(Some)
}

#[cfg(unix)]
pub(crate) fn create_symlink(target: &Path, link: &Path) -> Result<()> {
    std::os::unix::fs::symlink(target, link).context("Failed to create credential symlink")
}

#[cfg(windows)]
pub(crate) fn create_symlink(target: &Path, link: &Path) -> Result<()> {
    match std::os::windows::fs::symlink_file(target, link) {
        Ok(()) => Ok(()),
        Err(_) => std::fs::copy(target, link)
            .map(|_| ())
            .context("Failed to copy credentials"),
    }
}

#[cfg(not(any(unix, windows)))]
pub(crate) fn create_symlink(target: &Path, link: &Path) -> Result<()> {
    std::fs::copy(target, link)
        .map(|_| ())
        .context("Failed to copy credentials")
}

/// Symlinks `~/.claude/.credentials.json` → profile's `credentials.json`;
/// copies on Windows without symlink privilege. Refuses to overwrite a
/// regular file at the live path unless its content already matches the
/// profile target — replacing a divergent regular file would silently
/// destroy whatever CC wrote there (typically a re-login the user hasn't
/// resolved yet).
pub(crate) fn link_profile_credentials(name: &str) -> Result<()> {
    with_state_lock(|| {
        let link = claude_credentials_path()?;
        let target = profile_dir(name)?.join("credentials.json");

        if let Ok(meta) = link.symlink_metadata() {
            if !meta.file_type().is_symlink() {
                let live_bytes = std::fs::read(&link).ok();
                let target_bytes = std::fs::read(&target).ok();
                if live_bytes != target_bytes {
                    anyhow::bail!(
                        "refusing to replace .credentials.json — live file differs from profile '{name}'; resolve divergence first"
                    );
                }
            }
            std::fs::remove_file(&link).context("Failed to remove old .credentials.json")?;
        }

        if target.exists() {
            if let Some(parent) = link.parent() {
                std::fs::create_dir_all(parent)?;
            }
            create_symlink(&target, &link)?;
        }

        Ok(())
    })
}

pub(crate) fn clear_claude_credentials() -> Result<()> {
    with_state_lock(|| {
        let link = claude_credentials_path()?;
        if link.symlink_metadata().is_ok() {
            std::fs::remove_file(&link).context("Failed to remove .credentials.json")?;
        }
        Ok(())
    })
}

pub(crate) struct ClaudeEndpoint {
    pub(crate) base_url: Option<String>,
    pub(crate) api_key: Option<String>,
}

pub(crate) fn read_claude_endpoint_config() -> Result<ClaudeEndpoint> {
    let path = claude_settings_path()?;
    if !path.exists() {
        return Ok(ClaudeEndpoint {
            base_url: None,
            api_key: None,
        });
    }
    let content = std::fs::read_to_string(&path).context("Failed to read settings.json")?;
    let settings: serde_json::Value =
        serde_json::from_str(&content).context("Failed to parse settings.json")?;
    Ok(ClaudeEndpoint {
        base_url: settings["env"]["ANTHROPIC_BASE_URL"]
            .as_str()
            .map(str::to_owned),
        api_key: settings["env"]["ANTHROPIC_AUTH_TOKEN"]
            .as_str()
            .map(str::to_owned),
    })
}

/// Patches `settings.json`'s `env` block with ANTHROPIC_BASE_URL,
/// ANTHROPIC_AUTH_TOKEN, and the profile's `env` map. Keys in `prev_env_keys`
/// that the new profile doesn't carry are removed first so stale entries from
/// the previously active profile don't linger. Every other field is untouched.
pub(crate) fn apply_profile_to_claude_settings(
    profile: &Profile,
    prev_env_keys: &[String],
) -> Result<()> {
    with_state_lock(|| apply_profile_to_claude_settings_inner(profile, prev_env_keys))
}

fn apply_profile_to_claude_settings_inner(
    profile: &Profile,
    prev_env_keys: &[String],
) -> Result<()> {
    let path = claude_settings_path()?;

    let has_anything = profile.base_url.is_some()
        || profile.api_key.is_some()
        || !profile.env.is_empty()
        || !prev_env_keys.is_empty();
    if !has_anything && !path.exists() {
        return Ok(());
    }

    let content = build_claude_settings_json(&path, profile, prev_env_keys)?;
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    atomic_write(&path, content).context("Failed to write settings.json")
}

/// Merges `base_path`'s settings.json (or `{}` when missing) with the profile's
/// endpoint keys and env overlay. `prev_env_keys` lists env keys to strip
/// before applying the new profile — used by the switch path to clear the
/// previously active profile's custom env. `start` passes `&[]` so existing
/// keys in the file stay untouched.
pub(crate) fn build_claude_settings_json(
    base_path: &Path,
    profile: &Profile,
    prev_env_keys: &[String],
) -> Result<String> {
    let mut settings: serde_json::Value = if base_path.exists() {
        let content = std::fs::read_to_string(base_path).context("Failed to read settings.json")?;
        serde_json::from_str(&content).context("Failed to parse settings.json")?
    } else {
        serde_json::json!({})
    };

    if settings.get("env").is_none() {
        settings["env"] = serde_json::json!({});
    }

    let env = settings["env"]
        .as_object_mut()
        .context("settings.json `env` is not an object")?;

    for key in prev_env_keys {
        if !profile.env.contains_key(key) {
            env.remove(key);
        }
    }

    match profile.base_url.as_deref() {
        Some(url) => {
            env.insert("ANTHROPIC_BASE_URL".into(), url.into());
        }
        None => {
            env.remove("ANTHROPIC_BASE_URL");
        }
    }
    match profile.api_key.as_deref() {
        Some(key) => {
            env.insert("ANTHROPIC_AUTH_TOKEN".into(), key.into());
        }
        None => {
            env.remove("ANTHROPIC_AUTH_TOKEN");
        }
    }

    // Apply profile env last so an explicit ANTHROPIC_* entry in the profile
    // env map wins over the dedicated base_url / api_key fields.
    for (k, v) in &profile.env {
        env.insert(k.clone(), v.clone().into());
    }

    serde_json::to_string_pretty(&settings).context("Failed to serialize settings.json")
}

/// Reads the live .credentials.json and saves it to the active profile.
/// No-op when the live path has diverged from our symlink — accepting a
/// divergent live file as authoritative would silently overwrite the
/// profile's stored identity. The reconciliation modal resolves divergence
/// by calling `force_snapshot_active_credentials` after the user picks
/// "Overwrite".
pub(crate) fn snapshot_active_credentials(config: &mut AppConfig) -> Result<()> {
    with_state_lock(|| {
        let Some(active) = config.state.active_profile.clone() else {
            return Ok(());
        };
        if matches!(classify_credentials_link(&active)?, LinkState::Diverged) {
            // A divergent live file is normally a re-login the user must
            // resolve, so the stored identity stays untouched. The one
            // exception is a credential-less profile's first login: adopt
            // Claude Code's write so the profile gains an identity.
            if is_first_login(&active)? {
                adopt_first_login(config, &active)?;
            }
            return Ok(());
        }
        snapshot_active_credentials_unchecked(config, &active)
    })
}

/// Adopt a credential-less profile's first login: store the live
/// `.credentials.json` into the active profile, then replace it with a symlink
/// so later Claude Code writes stay owned. Callers gate this on
/// [`is_first_login`]; calling it otherwise would overwrite stored identity.
pub(crate) fn adopt_first_login(config: &mut AppConfig, active: &str) -> Result<()> {
    with_state_lock(|| {
        snapshot_active_credentials_unchecked(config, active)?;
        force_link_profile_credentials(active)
    })
}

fn snapshot_active_credentials_unchecked(config: &mut AppConfig, active: &str) -> Result<()> {
    let credentials = read_claude_credentials()?;
    if let Some(profile) = config.find_mut(active) {
        profile.credentials = credentials;
        save_profile(profile)?;
    }
    Ok(())
}

/// Snapshot the live .credentials.json into the active profile even when
/// the link is diverged. Called by the divergence-resolution modal's
/// "Overwrite active profile with live creds" action.
pub(crate) fn force_snapshot_active_credentials(config: &mut AppConfig) -> Result<()> {
    with_state_lock(|| {
        let Some(active) = config.state.active_profile.clone() else {
            return Ok(());
        };
        snapshot_active_credentials_unchecked(config, &active)
    })
}

/// Re-link `~/.claude/.credentials.json` to `name`'s stored credentials,
/// overwriting whatever's at the live path. Used by the divergence modal's
/// "Discard new creds" action to restore the profile's stored identity.
pub(crate) fn force_link_profile_credentials(name: &str) -> Result<()> {
    with_state_lock(|| {
        let link = claude_credentials_path()?;
        if link.symlink_metadata().is_ok() {
            std::fs::remove_file(&link).context("Failed to remove .credentials.json")?;
        }
        let target = profile_dir(name)?.join("credentials.json");
        if target.exists() {
            if let Some(parent) = link.parent() {
                std::fs::create_dir_all(parent)?;
            }
            create_symlink(&target, &link)?;
        }
        Ok(())
    })
}

/// Returns true when both sides carry an OAuth block and either the access
/// token or refresh token differs. Missing data on either side returns false
/// — the caller's normal snapshot/skip path is safer than guessing.
pub(crate) fn credentials_diverged(
    stored: Option<&ClaudeCredentials>,
    live: Option<&ClaudeCredentials>,
) -> bool {
    let Some(stored) = stored.and_then(|c| c.claude_ai_oauth.as_ref()) else {
        return false;
    };
    let Some(live) = live.and_then(|c| c.claude_ai_oauth.as_ref()) else {
        return false;
    };
    stored.access_token != live.access_token || stored.refresh_token != live.refresh_token
}

/// Replaces the symlink at `~/.claude/.credentials.json` with a regular file
/// containing the same bytes. No-op when the path is already a regular file
/// or doesn't exist. Called when the user disowns the active profile so
/// subsequent Claude Code writes don't bleed into that profile's storage
/// through the symlink.
pub(crate) fn detach_credentials_link() -> Result<()> {
    with_state_lock(|| {
        let path = claude_credentials_path()?;
        let Ok(meta) = path.symlink_metadata() else {
            return Ok(());
        };
        if !meta.file_type().is_symlink() {
            return Ok(());
        }
        let content =
            std::fs::read(&path).context("Failed to read .credentials.json before detach")?;
        std::fs::remove_file(&path).context("Failed to remove .credentials.json symlink")?;
        atomic_write(&path, content).context("Failed to write detached .credentials.json")?;
        Ok(())
    })
}

#[cfg(test)]
#[path = "../tests/inline/claude.rs"]
mod tests;