11use std:: path:: PathBuf ;
2- use std:: sync :: Mutex ;
2+ use std:: process :: Command ;
33use std:: time:: { Duration , SystemTime , UNIX_EPOCH } ;
44
55use crate :: models:: { UsageData , UsageSection } ;
66
77const API_URL : & str = "https://api.anthropic.com/v1/messages" ;
8- const TOKEN_URL : & str = "https://platform.claude.com/v1/oauth/token" ;
9- const CLIENT_ID : & str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" ;
10- const OAUTH_SCOPES : & str = "user:profile user:inference user:sessions:claude_code user:mcp_servers" ;
118
129const MODEL_FALLBACK_CHAIN : & [ & str ] = & [
1310 "claude-3-haiku-20240307" ,
1411 "claude-haiku-4-5-20251001" ,
1512] ;
1613
17- /// In-memory cache of refreshed tokens so we don't lose them between poll cycles
18- /// and don't need to write to the credentials file.
19- struct CachedTokens {
20- access_token : String ,
21- refresh_token : String ,
22- expires_at : i64 , // milliseconds since epoch
23- }
24-
25- static TOKEN_CACHE : Mutex < Option < CachedTokens > > = Mutex :: new ( None ) ;
26-
2714#[ derive( Debug ) ]
2815pub enum PollError {
2916 NoCredentials ,
@@ -32,53 +19,30 @@ pub enum PollError {
3219}
3320
3421pub fn poll ( ) -> Result < UsageData , PollError > {
35- let token = resolve_access_token ( ) ?;
36- fetch_usage_with_fallback ( & token)
37- }
38-
39- /// Resolve a valid access token by checking (in order):
40- /// 1. In-memory cache (from a previous refresh)
41- /// 2. On-disk credentials file
42- /// Then refresh if the token is expired.
43- fn resolve_access_token ( ) -> Result < String , PollError > {
44- // Try cached tokens first
45- {
46- let cache = TOKEN_CACHE . lock ( ) . unwrap ( ) ;
47- if let Some ( cached) = cache. as_ref ( ) {
48- if !is_token_expired ( Some ( cached. expires_at ) ) {
49- return Ok ( cached. access_token . clone ( ) ) ;
50- }
51- // Cached token expired — we'll refresh below using the cached refresh token
52- }
53- }
54-
55- // Get the refresh token to use: prefer cached (may be rotated), fall back to disk
56- let ( access_token, refresh_token, expires_at) = {
57- let cache = TOKEN_CACHE . lock ( ) . unwrap ( ) ;
58- if let Some ( cached) = cache. as_ref ( ) {
59- ( cached. access_token . clone ( ) , Some ( cached. refresh_token . clone ( ) ) , Some ( cached. expires_at ) )
60- } else {
61- let creds = read_credentials ( ) . ok_or ( PollError :: NoCredentials ) ?;
62- ( creds. access_token , creds. refresh_token , creds. expires_at )
22+ let mut creds = read_credentials ( ) . ok_or ( PollError :: NoCredentials ) ?;
23+
24+ if is_token_expired ( creds. expires_at ) {
25+ // Token expired — ask the Claude CLI to refresh its own credentials
26+ cli_refresh_token ( ) ;
27+ // Re-read the credentials file after CLI refresh
28+ creds = read_credentials ( ) . ok_or ( PollError :: NoCredentials ) ?;
29+ if is_token_expired ( creds. expires_at ) {
30+ return Err ( PollError :: TokenExpired ) ;
6331 }
64- } ;
65-
66- if !is_token_expired ( expires_at) {
67- return Ok ( access_token) ;
6832 }
6933
70- // Token is expired — refresh it
71- let refresh = refresh_token. ok_or ( PollError :: TokenExpired ) ?;
72- let new_tokens = refresh_access_token ( & refresh) . map_err ( |_| PollError :: TokenExpired ) ?;
73-
74- // Cache the new tokens
75- let new_access = new_tokens. access_token . clone ( ) ;
76- {
77- let mut cache = TOKEN_CACHE . lock ( ) . unwrap ( ) ;
78- * cache = Some ( new_tokens) ;
79- }
34+ fetch_usage_with_fallback ( & creds. access_token )
35+ }
8036
81- Ok ( new_access)
37+ /// Invoke the Claude CLI to trigger its internal token refresh.
38+ /// `claude auth status` checks auth state, which causes the CLI to
39+ /// refresh expired tokens and write updated credentials to disk.
40+ fn cli_refresh_token ( ) {
41+ let _ = Command :: new ( "claude" )
42+ . args ( [ "auth" , "status" ] )
43+ . stdout ( std:: process:: Stdio :: null ( ) )
44+ . stderr ( std:: process:: Stdio :: null ( ) )
45+ . status ( ) ;
8246}
8347
8448fn fetch_usage_with_fallback ( token : & str ) -> Result < UsageData , PollError > {
@@ -93,7 +57,6 @@ fn fetch_usage_with_fallback(token: &str) -> Result<UsageData, PollError> {
9357
9458struct Credentials {
9559 access_token : String ,
96- refresh_token : Option < String > ,
9760 expires_at : Option < i64 > ,
9861}
9962
@@ -107,7 +70,6 @@ fn read_credentials() -> Option<Credentials> {
10770 let oauth = json. get ( "claudeAiOauth" ) ?;
10871 Some ( Credentials {
10972 access_token : oauth. get ( "accessToken" ) ?. as_str ( ) ?. to_string ( ) ,
110- refresh_token : oauth. get ( "refreshToken" ) . and_then ( |v| v. as_str ( ) ) . map ( String :: from) ,
11173 expires_at : oauth. get ( "expiresAt" ) . and_then ( |v| v. as_i64 ( ) ) ,
11274 } )
11375}
@@ -121,62 +83,6 @@ fn is_token_expired(expires_at: Option<i64>) -> bool {
12183 now >= exp
12284}
12385
124- /// Refresh the OAuth token using the refresh grant.
125- /// Returns the full token set (access, refresh, expiry) for in-memory caching.
126- fn refresh_access_token ( refresh_token : & str ) -> Result < CachedTokens , String > {
127- let tls = std:: sync:: Arc :: new (
128- native_tls:: TlsConnector :: new ( ) . map_err ( |e| e. to_string ( ) ) ?
129- ) ;
130- let agent = ureq:: AgentBuilder :: new ( )
131- . timeout ( Duration :: from_secs ( 30 ) )
132- . tls_connector ( tls)
133- . build ( ) ;
134-
135- let body = serde_json:: json!( {
136- "grant_type" : "refresh_token" ,
137- "refresh_token" : refresh_token,
138- "client_id" : CLIENT_ID ,
139- "scope" : OAUTH_SCOPES ,
140- } ) ;
141-
142- let resp = agent
143- . post ( TOKEN_URL )
144- . set ( "Content-Type" , "application/json" )
145- . send_json ( & body)
146- . map_err ( |e| e. to_string ( ) ) ?;
147-
148- let resp_body: serde_json:: Value = resp
149- . into_json ( )
150- . map_err ( |e| e. to_string ( ) ) ?;
151-
152- let new_access = resp_body. get ( "access_token" )
153- . and_then ( |v| v. as_str ( ) )
154- . ok_or ( "missing access_token in refresh response" ) ?
155- . to_string ( ) ;
156-
157- // Use new refresh token if server returned one (rotation), otherwise keep existing
158- let new_refresh = resp_body. get ( "refresh_token" )
159- . and_then ( |v| v. as_str ( ) )
160- . unwrap_or ( refresh_token)
161- . to_string ( ) ;
162-
163- // Calculate expiry from expires_in (seconds), default to 1 hour if missing
164- let expires_in = resp_body. get ( "expires_in" )
165- . and_then ( |v| v. as_i64 ( ) )
166- . unwrap_or ( 3600 ) ;
167- let now_ms = SystemTime :: now ( )
168- . duration_since ( UNIX_EPOCH )
169- . unwrap_or_default ( )
170- . as_millis ( ) as i64 ;
171- let expires_at = now_ms + ( expires_in * 1000 ) ;
172-
173- Ok ( CachedTokens {
174- access_token : new_access,
175- refresh_token : new_refresh,
176- expires_at,
177- } )
178- }
179-
18086fn try_model ( token : & str , model : & str ) -> Option < UsageData > {
18187 let tls = std:: sync:: Arc :: new (
18288 native_tls:: TlsConnector :: new ( ) . ok ( ) ?
0 commit comments