use crate::secret::Secret;
use std::path::Path;
pub fn slot_token(dir: &Path) -> Option<Secret> {
slot_token_detail(dir).ok()
}
pub fn slot_token_detail(dir: &Path) -> Result<Secret, TokenUnavailable> {
if let Ok(bytes) = std::fs::read(dir.join(".credentials.json")) {
if let Some(t) = access_token(&bytes) {
return Ok(t);
}
}
use crate::adapters::claude::KeychainReadError as K;
match crate::adapters::claude::slot_keychain_read_detail(dir) {
Ok(bytes) => access_token(&bytes).ok_or(TokenUnavailable::NoLogin),
Err(K::Locked) => Err(TokenUnavailable::KeychainLocked),
Err(K::Missing | K::NotApplicable) => Err(TokenUnavailable::NoLogin),
}
}
pub enum TokenUnavailable {
NoLogin,
KeychainLocked,
}
impl TokenUnavailable {
pub fn remedy(&self, name: &str) -> String {
match self {
Self::NoLogin => format!(
"account '{name}' has no usable login - `swapdex run {name}` once signs it in"
),
Self::KeychainLocked => format!(
"account '{name}' is signed in, but macOS will not release its login here: \
reading a Keychain secret needs an unlocked login keychain, which a remote \
or non-interactive shell does not have. Run the proxy from a terminal on \
the Mac itself (or unlock with `security unlock-keychain`)."
),
}
}
}
pub fn slot_token_expired(dir: &Path, now_ms: i64) -> bool {
const SLACK_MS: i64 = 60_000;
let blob = std::fs::read(dir.join(".credentials.json"))
.ok()
.or_else(|| crate::adapters::claude::slot_keychain_read_detail(dir).ok());
blob.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
.and_then(|v| v["claudeAiOauth"]["expiresAt"].as_i64())
.is_some_and(|exp| exp - now_ms <= SLACK_MS)
}
pub fn slot_account_uuid(dir: &Path) -> Option<String> {
let bytes = std::fs::read(dir.join(".claude.json")).ok()?;
let v: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
v["oauthAccount"]["accountUuid"]
.as_str()
.filter(|s| !s.is_empty())
.map(str::to_string)
}
pub fn slot_email(dir: &Path) -> Option<String> {
let bytes = std::fs::read(dir.join(".claude.json")).ok()?;
let v: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
v["oauthAccount"]["emailAddress"]
.as_str()
.filter(|s| !s.is_empty())
.map(str::to_string)
}
fn access_token(bytes: &[u8]) -> Option<Secret> {
let v: serde_json::Value = serde_json::from_slice(bytes).ok()?;
let t = v["claudeAiOauth"]["accessToken"].as_str()?;
(!t.is_empty()).then(|| Secret::new(t.as_bytes().to_vec()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slot_token_reads_the_access_token_from_the_slot_file() {
let dir = tempfile::tempdir().unwrap();
assert!(slot_token(dir.path()).is_none(), "no login yet");
std::fs::write(
dir.path().join(".credentials.json"),
br#"{"claudeAiOauth":{"accessToken":"AT-1","refreshToken":"RT-1","expiresAt":1}}"#,
)
.unwrap();
let t = slot_token(dir.path()).expect("token");
assert_eq!(t.expose(), b"AT-1");
}
#[test]
fn slot_account_uuid_comes_from_the_slots_oauth_account() {
let dir = tempfile::tempdir().unwrap();
assert!(slot_account_uuid(dir.path()).is_none(), "no config yet");
std::fs::write(
dir.path().join(".claude.json"),
br#"{"oauthAccount":{"accountUuid":"u-1","emailAddress":"a@x.com"}}"#,
)
.unwrap();
assert_eq!(slot_account_uuid(dir.path()).as_deref(), Some("u-1"));
std::fs::write(
dir.path().join(".claude.json"),
br#"{"oauthAccount":{"accountUuid":""}}"#,
)
.unwrap();
assert!(slot_account_uuid(dir.path()).is_none());
}
#[test]
fn an_expired_slot_is_recognised_and_an_unknown_one_is_not() {
let dir = tempfile::tempdir().unwrap();
let now = 1_800_000_000_000i64;
assert!(!slot_token_expired(dir.path(), now));
let write = |exp: i64| {
std::fs::write(
dir.path().join(".credentials.json"),
format!(r#"{{"claudeAiOauth":{{"accessToken":"A","expiresAt":{exp}}}}}"#),
)
.unwrap()
};
write(now + 3_600_000);
assert!(!slot_token_expired(dir.path(), now), "an hour left is fine");
write(now - 1);
assert!(slot_token_expired(dir.path(), now), "already lapsed");
write(now + 30_000);
assert!(
slot_token_expired(dir.path(), now),
"about to lapse mid-flight counts as expired"
);
}
#[test]
fn slot_token_is_none_for_an_unparseable_credential() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".credentials.json"), b"not json").unwrap();
assert!(slot_token(dir.path()).is_none());
}
}