@@ -17,11 +17,20 @@ const MODEL_FALLBACK_CHAIN: &[&str] = &["claude-3-haiku-20240307", "claude-haiku
1717
1818#[ derive( Debug ) ]
1919pub enum PollError {
20+ AuthRequired ,
2021 NoCredentials ,
2122 TokenExpired ,
2223 RequestFailed ,
2324}
2425
26+ #[ derive( Clone , Copy , Debug , PartialEq , Eq ) ]
27+ pub enum CredentialWatchMode {
28+ ActiveSource ,
29+ AllSources ,
30+ }
31+
32+ pub type CredentialWatchSnapshot = Vec < String > ;
33+
2534#[ derive( Deserialize ) ]
2635struct UsageResponse {
2736 five_hour : Option < UsageBucket > ,
@@ -234,23 +243,112 @@ fn build_agent() -> Result<ureq::Agent, PollError> {
234243 . build ( ) )
235244}
236245
246+ pub fn credential_watch_snapshot ( mode : CredentialWatchMode ) -> CredentialWatchSnapshot {
247+ let sources = match mode {
248+ CredentialWatchMode :: ActiveSource => read_credentials ( )
249+ . map ( |creds| vec ! [ creds. source] )
250+ . unwrap_or_else ( all_known_credential_sources) ,
251+ CredentialWatchMode :: AllSources => all_known_credential_sources ( ) ,
252+ } ;
253+
254+ let mut snapshot: CredentialWatchSnapshot = sources
255+ . into_iter ( )
256+ . filter_map ( |source| credential_watch_signature ( & source) )
257+ . collect ( ) ;
258+ snapshot. sort ( ) ;
259+ snapshot. dedup ( ) ;
260+ snapshot
261+ }
262+
263+ fn all_known_credential_sources ( ) -> Vec < CredentialSource > {
264+ let mut sources = Vec :: new ( ) ;
265+ if let Some ( source) = windows_credential_source ( ) {
266+ sources. push ( source) ;
267+ }
268+ for distro in list_wsl_distros ( ) {
269+ sources. push ( CredentialSource :: Wsl { distro } ) ;
270+ }
271+ sources
272+ }
273+
274+ fn windows_credential_source ( ) -> Option < CredentialSource > {
275+ let home = dirs:: home_dir ( ) ?;
276+ Some ( CredentialSource :: Windows (
277+ home. join ( ".claude" ) . join ( ".credentials.json" ) ,
278+ ) )
279+ }
280+
281+ fn credential_watch_signature ( source : & CredentialSource ) -> Option < String > {
282+ match source {
283+ CredentialSource :: Windows ( path) => Some ( windows_credential_watch_signature ( path) ) ,
284+ CredentialSource :: Wsl { distro } => wsl_credential_watch_signature ( distro) ,
285+ }
286+ }
287+
288+ fn windows_credential_watch_signature ( path : & PathBuf ) -> String {
289+ let key = format ! ( "win:{}" , path. display( ) ) ;
290+ match std:: fs:: metadata ( path) {
291+ Ok ( metadata) => {
292+ let modified = metadata
293+ . modified ( )
294+ . ok ( )
295+ . and_then ( |value| value. duration_since ( UNIX_EPOCH ) . ok ( ) )
296+ . map ( |value| value. as_secs ( ) )
297+ . unwrap_or ( 0 ) ;
298+ format ! ( "{key}|present|{}|{modified}" , metadata. len( ) )
299+ }
300+ Err ( _) => format ! ( "{key}|missing" ) ,
301+ }
302+ }
303+
304+ fn wsl_credential_watch_signature ( distro : & str ) -> Option < String > {
305+ let output = run_with_timeout (
306+ Command :: new ( "wsl.exe" )
307+ . arg ( "-d" )
308+ . arg ( distro)
309+ . arg ( "--" )
310+ . arg ( "sh" )
311+ . arg ( "-lc" )
312+ . arg (
313+ "if [ -f ~/.claude/.credentials.json ]; then \
314+ stat -c 'present|%s|%Y' ~/.claude/.credentials.json; \
315+ else echo missing; fi",
316+ )
317+ . creation_flags ( CREATE_NO_WINDOW )
318+ . stdout ( std:: process:: Stdio :: piped ( ) )
319+ . stderr ( std:: process:: Stdio :: null ( ) ) ,
320+ Duration :: from_secs ( 5 ) ,
321+ ) ?;
322+
323+ let state = if output. status . success ( ) {
324+ decode_wsl_text ( & output. stdout ) . trim ( ) . to_string ( )
325+ } else {
326+ format ! ( "status-{}" , output. status)
327+ } ;
328+
329+ Some ( format ! ( "wsl:{distro}|{state}" ) )
330+ }
331+
237332fn fetch_usage_with_fallback ( token : & str ) -> Result < UsageData , PollError > {
238333 // Try the dedicated usage endpoint first
239- if let Some ( data) = try_usage_endpoint ( token) {
334+ match try_usage_endpoint ( token) ? {
335+ Some ( data) => {
240336 // If reset timers are missing, fill them in from the Messages API
241- if data. session . resets_at . is_none ( ) || data. weekly . resets_at . is_none ( ) {
242- if let Ok ( fallback) = fetch_usage_via_messages ( token) {
243- let mut merged = data;
244- if merged. session . resets_at . is_none ( ) {
245- merged. session . resets_at = fallback. session . resets_at ;
246- }
247- if merged. weekly . resets_at . is_none ( ) {
248- merged. weekly . resets_at = fallback. weekly . resets_at ;
337+ if data. session . resets_at . is_none ( ) || data. weekly . resets_at . is_none ( ) {
338+ if let Ok ( fallback) = fetch_usage_via_messages ( token) {
339+ let mut merged = data;
340+ if merged. session . resets_at . is_none ( ) {
341+ merged. session . resets_at = fallback. session . resets_at ;
342+ }
343+ if merged. weekly . resets_at . is_none ( ) {
344+ merged. weekly . resets_at = fallback. weekly . resets_at ;
345+ }
346+ return Ok ( merged) ;
249347 }
250- return Ok ( merged) ;
251348 }
349+ return Ok ( data) ;
252350 }
253- return Ok ( data ) ;
351+ None => { }
254352 }
255353
256354 // Fall back to Messages API with rate limit headers
@@ -261,8 +359,8 @@ fn fetch_usage_with_fallback(token: &str) -> Result<UsageData, PollError> {
261359 result
262360}
263361
264- fn try_usage_endpoint ( token : & str ) -> Option < UsageData > {
265- let agent = build_agent ( ) . ok ( ) ?;
362+ fn try_usage_endpoint ( token : & str ) -> Result < Option < UsageData > , PollError > {
363+ let agent = build_agent ( ) ?;
266364
267365 let resp = match agent
268366 . get ( USAGE_URL )
@@ -271,10 +369,19 @@ fn try_usage_endpoint(token: &str) -> Option<UsageData> {
271369 . call ( )
272370 {
273371 Ok ( resp) => resp,
274- _ => return None ,
372+ Err ( ureq:: Error :: Status ( code, _) ) if code == 401 || code == 403 => {
373+ diagnose:: log ( format ! (
374+ "usage endpoint returned auth error status {code}; re-login required"
375+ ) ) ;
376+ return Err ( PollError :: AuthRequired ) ;
377+ }
378+ Err ( _) => return Ok ( None ) ,
275379 } ;
276380
277- let response: UsageResponse = resp. into_json ( ) . ok ( ) ?;
381+ let response: UsageResponse = match resp. into_json ( ) {
382+ Ok ( response) => response,
383+ Err ( _) => return Ok ( None ) ,
384+ } ;
278385 let mut data = UsageData :: default ( ) ;
279386
280387 if let Some ( bucket) = & response. five_hour {
@@ -287,7 +394,7 @@ fn try_usage_endpoint(token: &str) -> Option<UsageData> {
287394 data. weekly . resets_at = parse_iso8601 ( bucket. resets_at . as_deref ( ) ) ;
288395 }
289396
290- Some ( data)
397+ Ok ( Some ( data) )
291398}
292399
293400fn fetch_usage_via_messages ( token : & str ) -> Result < UsageData , PollError > {
@@ -308,6 +415,12 @@ fn fetch_usage_via_messages(token: &str) -> Result<UsageData, PollError> {
308415 . send_json ( & body)
309416 {
310417 Ok ( resp) => resp,
418+ Err ( ureq:: Error :: Status ( code, _) ) if code == 401 || code == 403 => {
419+ diagnose:: log ( format ! (
420+ "messages endpoint returned auth error status {code}; re-login required"
421+ ) ) ;
422+ return Err ( PollError :: AuthRequired ) ;
423+ }
311424 Err ( ureq:: Error :: Status ( _code, resp) ) => resp,
312425 Err ( _) => continue ,
313426 } ;
@@ -410,8 +523,9 @@ fn read_credentials() -> Option<Credentials> {
410523}
411524
412525fn read_windows_credentials ( ) -> Option < Credentials > {
413- let home = dirs:: home_dir ( ) ?;
414- let cred_path = home. join ( ".claude" ) . join ( ".credentials.json" ) ;
526+ let CredentialSource :: Windows ( cred_path) = windows_credential_source ( ) ? else {
527+ return None ;
528+ } ;
415529 let content = match std:: fs:: read_to_string ( & cred_path) {
416530 Ok ( content) => content,
417531 Err ( error) => {
0 commit comments