11using System ;
22using System . Collections . Generic ;
3+ using System . Diagnostics ;
34using System . IO ;
45using System . Net ;
56using System . Net . Http ;
67using System . Net . Http . Headers ;
8+ using System . Runtime . InteropServices ;
79using System . Text . Json ;
810using System . Threading . Tasks ;
911using ClaudeUsageMonitor . Models ;
@@ -14,9 +16,13 @@ namespace ClaudeUsageMonitor.Services;
1416
1517public class UsageService : IDisposable
1618{
19+ private static readonly bool IsMacOS = RuntimeInformation . IsOSPlatform ( OSPlatform . OSX ) ;
20+
1721 private static readonly string CredentialsPath =
1822 Path . Combine ( Environment . GetFolderPath ( Environment . SpecialFolder . UserProfile ) , ".claude" , ".credentials.json" ) ;
1923
24+ private const string KeychainServiceName = "Claude Code-credentials" ;
25+
2026 private const string TokenRefreshUrl = "https://platform.claude.com/v1/oauth/token" ;
2127 private const string UsageUrl = "https://api.anthropic.com/api/oauth/usage" ;
2228 private const string ClientId = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" ;
@@ -26,9 +32,25 @@ public class UsageService : IDisposable
2632
2733 public void Dispose ( ) => _httpClient . Dispose ( ) ;
2834
29- public static bool CredentialsFileExists ( ) => File . Exists ( CredentialsPath ) ;
35+ public static bool CredentialsExist ( )
36+ {
37+ if ( ! IsMacOS )
38+ return File . Exists ( CredentialsPath ) ;
3039
31- public static string CredentialsFilePath => CredentialsPath ;
40+ try
41+ {
42+ var json = ReadKeychain ( ) ;
43+ return json != null ;
44+ }
45+ catch
46+ {
47+ return false ;
48+ }
49+ }
50+
51+ public static string CredentialsLocation => IsMacOS
52+ ? $ "macOS Keychain (service: \" { KeychainServiceName } \" )"
53+ : CredentialsPath ;
3254
3355 public async Task < UsageResponse ? > GetUsageAsync ( )
3456 {
@@ -103,19 +125,85 @@ private readonly record struct UsageRequestResult(
103125
104126 private async Task LoadCredentialsAsync ( )
105127 {
106- // Always read fresh from Claude Code's credentials file so we pick up any
107- // refresh it performs, and never hold a stale refresh_token that the server
108- // has rotated away under us.
109- if ( ! File . Exists ( CredentialsPath ) )
128+ // Always read fresh so we pick up any refresh Claude Code performs,
129+ // and never hold a stale refresh_token that the server has rotated.
130+ string ? json ;
131+
132+ if ( IsMacOS )
110133 {
111- throw new FileNotFoundException ( $ "No credentials file found at { CredentialsPath } ") ;
134+ json = ReadKeychain ( ) ;
135+ if ( json == null )
136+ throw new InvalidOperationException (
137+ $ "No credentials found in macOS Keychain (service: \" { KeychainServiceName } \" )") ;
138+ }
139+ else
140+ {
141+ if ( ! File . Exists ( CredentialsPath ) )
142+ throw new FileNotFoundException ( $ "No credentials file found at { CredentialsPath } ") ;
143+
144+ json = await File . ReadAllTextAsync ( CredentialsPath ) ;
112145 }
113146
114- var json = await File . ReadAllTextAsync ( CredentialsPath ) ;
115147 var credFile = JsonSerializer . Deserialize ( json , UsageJsonContext . Default . CredentialsFile ) ;
116148 _credentials = credFile ? . ClaudeAiOauth ;
117149 }
118150
151+ private static string ? ReadKeychain ( )
152+ {
153+ using var process = Process . Start ( new ProcessStartInfo
154+ {
155+ FileName = "/usr/bin/security" ,
156+ Arguments = $ "find-generic-password -s \" { KeychainServiceName } \" -w",
157+ RedirectStandardOutput = true ,
158+ RedirectStandardError = true ,
159+ UseShellExecute = false ,
160+ CreateNoWindow = true
161+ } ) ;
162+
163+ if ( process == null )
164+ return null ;
165+
166+ var output = process . StandardOutput . ReadToEnd ( ) . Trim ( ) ;
167+ process . WaitForExit ( ) ;
168+
169+ return process . ExitCode == 0 && output . Length > 0 ? output : null ;
170+ }
171+
172+ private static void WriteKeychain ( string json )
173+ {
174+ // Delete the existing entry first (security add fails if it already exists)
175+ using var delete = Process . Start ( new ProcessStartInfo
176+ {
177+ FileName = "/usr/bin/security" ,
178+ Arguments = $ "delete-generic-password -s \" { KeychainServiceName } \" ",
179+ RedirectStandardOutput = true ,
180+ RedirectStandardError = true ,
181+ UseShellExecute = false ,
182+ CreateNoWindow = true
183+ } ) ;
184+ delete ? . WaitForExit ( ) ;
185+
186+ using var add = Process . Start ( new ProcessStartInfo
187+ {
188+ FileName = "/usr/bin/security" ,
189+ Arguments = $ "add-generic-password -s \" { KeychainServiceName } \" -a \" { Environment . UserName } \" -w \" { json . Replace ( "\" " , "\\ \" " ) } \" ",
190+ RedirectStandardOutput = true ,
191+ RedirectStandardError = true ,
192+ UseShellExecute = false ,
193+ CreateNoWindow = true
194+ } ) ;
195+
196+ if ( add == null )
197+ return ;
198+
199+ add . WaitForExit ( ) ;
200+ if ( add . ExitCode != 0 )
201+ {
202+ var error = add . StandardError . ReadToEnd ( ) ;
203+ throw new InvalidOperationException ( $ "Failed to write credentials to Keychain: { error } ") ;
204+ }
205+ }
206+
119207 private async Task RefreshTokenAsync ( )
120208 {
121209 if ( _credentials ? . RefreshToken == null )
@@ -164,6 +252,10 @@ private async Task RefreshTokenAsync()
164252 ClaudeAiOauth = _credentials
165253 } ;
166254 var updatedJson = JsonSerializer . Serialize ( credFile , UsageJsonContext . Default . CredentialsFile ) ;
167- await File . WriteAllTextAsync ( CredentialsPath , updatedJson ) ;
255+
256+ if ( IsMacOS )
257+ WriteKeychain ( updatedJson ) ;
258+ else
259+ await File . WriteAllTextAsync ( CredentialsPath , updatedJson ) ;
168260 }
169261}
0 commit comments