use super::{configfile, document, live, paths as claude};
use crate::api::{self, ApiError};
use crate::context::Context;
use crate::provider::{
Adoption, Credential, Expiry, Identity, Isolation, LiveStore, ParkSemantics, Provider,
ProviderError, ProviderId, SignInView,
};
use crate::service::Permit;
use crate::switch;
use crate::usage;
use serde_json::Value;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy)]
pub(crate) struct Claude;
const ADOPTION_SECONDS: u32 = switch::ADOPTION_CEILING_SECONDS;
const ASKS_FOR_A_CODE: &str = "Paste code";
const REFUSES_A_CODE: &str = "Invalid code";
impl Provider for Claude {
fn id(&self) -> ProviderId {
ProviderId::Claude
}
fn live(&self, ctx: &Context) -> Result<LiveStore, ProviderError> {
Ok(LiveStore {
chain: live::chain(ctx),
service: claude::live_service(ctx),
})
}
fn identify(&self, ctx: &Context, credential: &Credential) -> Result<Identity, ProviderError> {
let token =
access_token(&credential.raw).ok_or_else(|| ProviderError::ShapeUnexpected {
provider: ProviderId::Claude,
detail: "it has no claudeAiOauth.accessToken".into(),
})?;
api::owner(ctx, token).map(from_owner).map_err(from_api)
}
fn identifies_by_itself(&self) -> bool {
false
}
fn usage(
&self,
ctx: &Context,
credential: &Credential,
) -> Result<usage::Snapshot, ProviderError> {
let token =
access_token(&credential.raw).ok_or_else(|| ProviderError::ShapeUnexpected {
provider: ProviderId::Claude,
detail: "it has no claudeAiOauth.accessToken".into(),
})?;
api::usage(ctx, token).map_err(from_api)
}
fn renew(
&self,
ctx: &Context,
permit: Permit,
credential: &Credential,
) -> Result<Credential, ProviderError> {
let oauth = document::oauth_in(&credential.raw);
let refresh = oauth["refreshToken"].as_str().unwrap_or_default();
let mut scopes: Vec<String> = oauth["scopes"]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(str::to_owned)
.collect();
if scopes.is_empty() {
scopes = switch::renew::DEFAULT_SCOPES.map(str::to_owned).to_vec();
}
let client_id = oauth["clientId"].as_str();
let fresh = api::renew(ctx, permit, refresh, &scopes, client_id).map_err(from_api)?;
let at_millis = fresh.at.map_or_else(|| ctx.now_millis(), |at| at * 1000);
Ok(Credential::new(
ProviderId::Claude,
document::renewed(&credential.raw, &fresh, at_millis),
))
}
fn slot(&self, ctx: &Context) -> String {
claude::live_service(ctx)
}
fn write_lock(&self, ctx: &Context) -> Option<PathBuf> {
Some(PathBuf::from(claude::storage_dir(ctx)).join(".storage-write"))
}
fn recorded_identity(&self, ctx: &Context) -> Option<Identity> {
let config = claude::load_config(ctx).ok()?;
let found = claude::identity(&config)?;
Some(Identity {
account_id: found.account_uuid,
email: found.email,
group: Some(found.organization_uuid).filter(|o| !o.is_empty()),
})
}
fn after_switch(
&self,
ctx: &Context,
permit: Permit,
incoming: &crate::state::Account,
outgoing: &Identity,
) -> Result<(), crate::error::Error> {
let path = claude::config_file(ctx);
let outgoing_group = outgoing.group.clone().unwrap_or_default();
configfile::backup(ctx, permit, &path)?;
configfile::update(ctx, permit, &path, |config| {
configfile::splice_identity(
config,
incoming.claude().map_or(&Value::Null, |c| c.oauth_account),
&[outgoing.account_id.as_str(), outgoing_group.as_str()],
)
})
.map(|_| ())
}
fn program(&self, ctx: &Context) -> Option<PathBuf> {
claude::program(ctx)
}
fn sign_in(
&self,
ctx: &Context,
permit: Permit,
dir: &std::path::Path,
) -> std::process::Command {
let mut command = crate::provider::command(ctx, permit, ProviderId::Claude);
command
.args(["auth", "login"])
.env("CLAUDE_CONFIG_DIR", dir)
.env_remove("CLAUDE_SECURESTORAGE_CONFIG_DIR");
command
}
fn read_signin(
&self,
ctx: &Context,
dir: &std::path::Path,
) -> Result<Option<String>, crate::store::Error> {
live::read_signin(ctx, dir)
}
fn discard_signin(&self, ctx: &Context, permit: Permit, dir: &std::path::Path) {
let _ = live::discard_signin(ctx, permit, dir);
}
fn read_sign_in(&self, said: &str) -> SignInView {
SignInView {
url: crate::provider::https_address(said),
wants_code: said.contains(ASKS_FOR_A_CODE),
}
}
fn refused_code(&self, since: &str) -> bool {
since.contains(REFUSES_A_CODE)
}
fn overridden_by(&self, ctx: &Context) -> Vec<String> {
crate::settings::overrides(ctx)
.iter()
.map(ToString::to_string)
.collect()
}
fn adoption(&self) -> Adoption {
Adoption::PollingWithin(ADOPTION_SECONDS)
}
fn park_semantics(&self) -> ParkSemantics {
ParkSemantics::CopyWhileLive
}
fn private_signin_isolation(&self, _ctx: &Context) -> Isolation {
Isolation::Isolated
}
fn slice(&self, live: &Value) -> Result<Value, ProviderError> {
if live.is_object() && live.get("claudeAiOauth").is_none() {
return Err(ProviderError::NoLogin {
provider: ProviderId::Claude,
});
}
document::slice(live).map_err(|detail| ProviderError::ShapeUnexpected {
provider: ProviderId::Claude,
detail,
})
}
fn splice(&self, live: &Value, incoming: &Value) -> Result<Value, ProviderError> {
document::splice(live, incoming).map_err(|detail| ProviderError::ShapeUnexpected {
provider: ProviderId::Claude,
detail,
})
}
fn fingerprint(&self, slice: &Value) -> String {
document::fingerprint_of(slice)
}
fn expiry(&self, slice: &Value) -> Expiry {
let oauth = document::oauth_in(slice);
let at = |key: &str| oauth.get(key).and_then(Value::as_i64).map(|ms| ms / 1000);
Expiry {
access_expires_at: at("expiresAt"),
refresh_expires_at: at("refreshTokenExpiresAt"),
}
}
}
fn access_token(login: &Value) -> Option<&str> {
document::oauth_in(login)
.get("accessToken")
.and_then(Value::as_str)
.filter(|token| !token.is_empty())
}
fn from_owner(owner: api::Owner) -> Identity {
Identity {
account_id: owner.account_uuid,
email: owner.email,
group: Some(owner.organization_uuid).filter(|o| !o.is_empty()),
}
}
fn from_api(error: ApiError) -> ProviderError {
match error {
ApiError::Unauthorized => ProviderError::Unauthorized,
ApiError::RateLimited { retry_after } => ProviderError::RateLimited {
service: ProviderId::Claude.service(),
retry_after,
},
ApiError::Network(detail) => ProviderError::Network {
service: ProviderId::Claude.service(),
detail,
},
ApiError::Unexpected { status } => ProviderError::Unexpected {
service: ProviderId::Claude.service(),
status,
},
ApiError::Malformed(detail) => ProviderError::Malformed {
service: ProviderId::Claude.service(),
detail,
},
ApiError::InvalidGrant => ProviderError::InvalidGrant {
service: ProviderId::Claude.service(),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
const ADDRESS: &str = "https://claude.com/cai/oauth/authorize?code=true\
&client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e&response_type=code\
&redirect_uri=https%3A%2F%2Fplatform.claude.com%2Foauth%2Fcode%2Fcallback\
&scope=org%3Acreate_api_key+user%3Aprofile&code_challenge=c&code_challenge_method=S256\
&state=s";
fn said(address: &str) -> String {
format!(
"Opening browser to sign in\u{2026}\n\
If the browser didn't open, visit: {address}\n\
Paste code here if prompted > "
)
}
#[test]
fn a_sign_in_offers_its_address_and_a_field_for_the_code_together() {
let said = said(ADDRESS);
let read = Claude.read_sign_in(&said);
assert_eq!(read.url.as_deref(), Some(ADDRESS));
assert!(read.wants_code);
let opening = &said[..said.find('\n').expect("a first line")];
assert_eq!(Claude.read_sign_in(opening), SignInView::default());
}
#[test]
fn a_code_is_refused_by_what_is_said_after_it() {
let refused = "Invalid code. Please make sure the full code was copied.\n";
assert!(Claude.refused_code(refused));
assert!(!Claude.refused_code(""));
assert!(!Claude.refused_code(&said(ADDRESS)));
assert!(!Claude.refused_code("Inval"));
assert!(Claude.refused_code(&["Inval", "id code. Please"].concat()));
}
#[test]
fn the_refusal_the_register_holds_is_read_as_one() {
let fact = crate::assumptions::named("sign_in_takes_another_code").expect("listed");
let written = fact
.probe
.iter()
.find(|probe| probe.contains("make sure the full code"))
.expect("the refusal is probed for");
let refusal = &written[written.find(REFUSES_A_CODE).expect("the refusal")..];
assert!(Claude.refused_code(refusal));
assert!(refusal.starts_with("Invalid code. Please make sure the full code was copied."));
}
#[test]
fn an_address_printed_as_a_terminal_hyperlink_is_the_one_it_links_to() {
for text in [ADDRESS.to_owned(), format!("\u{1b}[94m{ADDRESS}\u{1b}[39m")] {
let printed = format!("\u{1b}]8;;{ADDRESS}\u{7}{text}\u{1b}]8;;\u{7}");
let read = Claude.read_sign_in(&said(&printed));
assert_eq!(read.url.as_deref(), Some(ADDRESS), "{printed:?}");
assert!(read.wants_code);
}
}
#[test]
fn the_prompt_looked_for_is_in_the_register() {
let fact = crate::assumptions::named("sign_in_output").expect("listed");
assert!(
fact.probe.iter().any(|p| p.starts_with(ASKS_FOR_A_CODE)),
"{:?}",
fact.probe
);
}
}