@@ -42,9 +42,9 @@ pub fn poll() -> Result<UsageData, PollError> {
4242 } ;
4343
4444 if is_token_expired ( creds. expires_at ) {
45- cli_refresh_token ( ) ;
45+ cli_refresh_token ( & creds . source ) ;
4646
47- match read_credentials ( ) {
47+ match read_credentials_from_source ( & creds . source ) {
4848 Some ( refreshed) => creds = refreshed,
4949 None => return Err ( PollError :: NoCredentials ) ,
5050 }
@@ -59,8 +59,15 @@ pub fn poll() -> Result<UsageData, PollError> {
5959
6060/// Invoke the Claude CLI with a minimal prompt to force its internal
6161/// OAuth token refresh.
62- fn cli_refresh_token ( ) {
63- let claude_path = resolve_claude_path ( ) ;
62+ fn cli_refresh_token ( source : & CredentialSource ) {
63+ match source {
64+ CredentialSource :: Windows ( _) => cli_refresh_windows_token ( ) ,
65+ CredentialSource :: Wsl { distro } => cli_refresh_wsl_token ( distro) ,
66+ }
67+ }
68+
69+ fn cli_refresh_windows_token ( ) {
70+ let claude_path = resolve_windows_claude_path ( ) ;
6471 let is_cmd = claude_path. to_lowercase ( ) . ends_with ( ".cmd" ) ;
6572
6673 let args: & [ & str ] = & [ "-p" , "." ] ;
@@ -103,8 +110,49 @@ fn cli_refresh_token() {
103110 }
104111}
105112
113+ fn cli_refresh_wsl_token ( distro : & str ) {
114+ let mut cmd = Command :: new ( "wsl.exe" ) ;
115+ cmd. arg ( "-d" )
116+ . arg ( distro)
117+ . arg ( "--" )
118+ . arg ( "bash" )
119+ . arg ( "-lic" )
120+ . arg ( "if command -v claude >/dev/null 2>&1; then claude -p .; elif [ -x \" $HOME/.local/bin/claude\" ]; then \" $HOME/.local/bin/claude\" -p .; else exit 127; fi" )
121+ . env_remove ( "CLAUDECODE" )
122+ . env_remove ( "CLAUDE_CODE_ENTRYPOINT" )
123+ . creation_flags ( CREATE_NO_WINDOW )
124+ . stdin ( std:: process:: Stdio :: null ( ) )
125+ . stdout ( std:: process:: Stdio :: null ( ) )
126+ . stderr ( std:: process:: Stdio :: null ( ) ) ;
127+
128+ let mut child = match cmd. spawn ( ) {
129+ Ok ( c) => c,
130+ Err ( _) => return ,
131+ } ;
132+
133+ wait_for_refresh ( & mut child) ;
134+ }
135+
136+ fn wait_for_refresh ( child : & mut std:: process:: Child ) {
137+ // Wait up to 30 seconds; don't block the poll thread forever.
138+ let start = std:: time:: Instant :: now ( ) ;
139+ loop {
140+ match child. try_wait ( ) {
141+ Ok ( Some ( _) ) => break ,
142+ Ok ( None ) => {
143+ if start. elapsed ( ) > Duration :: from_secs ( 30 ) {
144+ let _ = child. kill ( ) ;
145+ break ;
146+ }
147+ std:: thread:: sleep ( Duration :: from_millis ( 500 ) ) ;
148+ }
149+ Err ( _) => break ,
150+ }
151+ }
152+ }
153+
106154/// Resolve the full path to the `claude` CLI executable.
107- fn resolve_claude_path ( ) -> String {
155+ fn resolve_windows_claude_path ( ) -> String {
108156 for name in & [ "claude.cmd" , "claude" ] {
109157 if Command :: new ( name)
110158 . arg ( "--version" )
@@ -283,14 +331,77 @@ fn unix_to_system_time(unix_secs: Option<i64>) -> Option<SystemTime> {
283331struct Credentials {
284332 access_token : String ,
285333 expires_at : Option < i64 > ,
334+ source : CredentialSource ,
335+ }
336+
337+ #[ derive( Clone , Debug ) ]
338+ enum CredentialSource {
339+ Windows ( PathBuf ) ,
340+ Wsl { distro : String } ,
286341}
287342
288343fn read_credentials ( ) -> Option < Credentials > {
289- let home = dirs:: home_dir ( ) ?;
290- let cred_path: PathBuf = home. join ( ".claude" ) . join ( ".credentials.json" ) ;
344+ let mut candidates = Vec :: new ( ) ;
345+
346+ if let Some ( creds) = read_windows_credentials ( ) {
347+ candidates. push ( creds) ;
348+ }
291349
350+ for distro in list_wsl_distros ( ) {
351+ if let Some ( creds) = read_wsl_credentials ( & distro) {
352+ candidates. push ( creds) ;
353+ }
354+ }
355+
356+ choose_best_credentials ( candidates)
357+ }
358+
359+ fn read_windows_credentials ( ) -> Option < Credentials > {
360+ let home = dirs:: home_dir ( ) ?;
361+ let cred_path = home. join ( ".claude" ) . join ( ".credentials.json" ) ;
292362 let content = std:: fs:: read_to_string ( & cred_path) . ok ( ) ?;
293- let json: serde_json:: Value = serde_json:: from_str ( & content) . ok ( ) ?;
363+ parse_credentials ( & content, CredentialSource :: Windows ( cred_path) )
364+ }
365+
366+ fn read_credentials_from_source ( source : & CredentialSource ) -> Option < Credentials > {
367+ match source {
368+ CredentialSource :: Windows ( path) => {
369+ let content = std:: fs:: read_to_string ( path) . ok ( ) ?;
370+ parse_credentials ( & content, source. clone ( ) )
371+ }
372+ CredentialSource :: Wsl { distro } => read_wsl_credentials ( distro) ,
373+ }
374+ }
375+
376+ fn read_wsl_credentials ( distro : & str ) -> Option < Credentials > {
377+ let output = Command :: new ( "wsl.exe" )
378+ . arg ( "-d" )
379+ . arg ( distro)
380+ . arg ( "--" )
381+ . arg ( "sh" )
382+ . arg ( "-lc" )
383+ . arg ( "cat ~/.claude/.credentials.json" )
384+ . creation_flags ( CREATE_NO_WINDOW )
385+ . stdout ( std:: process:: Stdio :: piped ( ) )
386+ . stderr ( std:: process:: Stdio :: null ( ) )
387+ . output ( )
388+ . ok ( ) ?;
389+
390+ if !output. status . success ( ) {
391+ return None ;
392+ }
393+
394+ let content = String :: from_utf8 ( output. stdout ) . ok ( ) ?;
395+ parse_credentials (
396+ & content,
397+ CredentialSource :: Wsl {
398+ distro : distro. to_string ( ) ,
399+ } ,
400+ )
401+ }
402+
403+ fn parse_credentials ( content : & str , source : CredentialSource ) -> Option < Credentials > {
404+ let json: serde_json:: Value = serde_json:: from_str ( content) . ok ( ) ?;
294405
295406 let oauth = json. get ( "claudeAiOauth" ) ?;
296407 let access_token = oauth. get ( "accessToken" ) . and_then ( |v| v. as_str ( ) ) ?. to_string ( ) ;
@@ -299,9 +410,88 @@ fn read_credentials() -> Option<Credentials> {
299410 Some ( Credentials {
300411 access_token,
301412 expires_at,
413+ source,
302414 } )
303415}
304416
417+ fn choose_best_credentials ( mut candidates : Vec < Credentials > ) -> Option < Credentials > {
418+ if candidates. is_empty ( ) {
419+ return None ;
420+ }
421+
422+ candidates. sort_by_key ( |creds| is_token_expired ( creds. expires_at ) ) ;
423+ candidates. into_iter ( ) . next ( )
424+ }
425+
426+ fn list_wsl_distros ( ) -> Vec < String > {
427+ let output = match Command :: new ( "wsl.exe" )
428+ . args ( [ "-l" , "-q" ] )
429+ . creation_flags ( CREATE_NO_WINDOW )
430+ . stdout ( std:: process:: Stdio :: piped ( ) )
431+ . stderr ( std:: process:: Stdio :: null ( ) )
432+ . output ( )
433+ {
434+ Ok ( output) if output. status . success ( ) => output,
435+ _ => return Vec :: new ( ) ,
436+ } ;
437+
438+ let stdout = decode_wsl_text ( & output. stdout ) ;
439+ stdout
440+ . lines ( )
441+ . map ( str:: trim)
442+ . filter ( |line| !line. is_empty ( ) )
443+ . map ( ToOwned :: to_owned)
444+ . collect ( )
445+ }
446+
447+ fn decode_wsl_text ( bytes : & [ u8 ] ) -> String {
448+ if bytes. is_empty ( ) {
449+ return String :: new ( ) ;
450+ }
451+
452+ if let Some ( decoded) = decode_utf16le ( bytes) {
453+ return decoded;
454+ }
455+
456+ String :: from_utf8_lossy ( bytes) . into_owned ( )
457+ }
458+
459+ fn decode_utf16le ( bytes : & [ u8 ] ) -> Option < String > {
460+ if bytes. len ( ) < 2 || bytes. len ( ) % 2 != 0 {
461+ return None ;
462+ }
463+
464+ let body = if bytes. starts_with ( & [ 0xFF , 0xFE ] ) {
465+ & bytes[ 2 ..]
466+ } else if looks_like_utf16le ( bytes) {
467+ bytes
468+ } else {
469+ return None ;
470+ } ;
471+
472+ let units: Vec < u16 > = body
473+ . chunks_exact ( 2 )
474+ . map ( |chunk| u16:: from_le_bytes ( [ chunk[ 0 ] , chunk[ 1 ] ] ) )
475+ . collect ( ) ;
476+
477+ Some ( String :: from_utf16_lossy ( & units) )
478+ }
479+
480+ fn looks_like_utf16le ( bytes : & [ u8 ] ) -> bool {
481+ let sample_len = bytes. len ( ) . min ( 128 ) ;
482+ let units = sample_len / 2 ;
483+ if units == 0 {
484+ return false ;
485+ }
486+
487+ let nul_high_bytes = bytes[ ..sample_len]
488+ . chunks_exact ( 2 )
489+ . filter ( |chunk| chunk[ 1 ] == 0 )
490+ . count ( ) ;
491+
492+ nul_high_bytes * 2 >= units
493+ }
494+
305495fn is_token_expired ( expires_at : Option < i64 > ) -> bool {
306496 let Some ( exp) = expires_at else { return false } ;
307497 let now = SystemTime :: now ( )
0 commit comments