| 1 | using System.Net.Http.Headers; |
| 2 | using System.Net.Http.Json; |
| 3 | using System.Text.Json; |
| 4 | using AgentForge.Credentials; |
| 5 | |
| 6 | return await ForgeCli.RunAsync(args); |
| 7 | |
| 8 | internal static class ForgeCli |
| 9 | { |
| 10 | private const string DefaultHost = "http://127.0.0.1:8770"; |
| 11 | private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; |
| 12 | |
| 13 | public static async Task<int> RunAsync(string[] args) |
| 14 | { |
| 15 | if (args.Length == 0 || args is ["--help"] or ["-h"] or ["help"]) |
| 16 | { |
| 17 | PrintHelp(); |
| 18 | return 0; |
| 19 | } |
| 20 | |
| 21 | if (!string.Equals(args[0], "auth", StringComparison.OrdinalIgnoreCase)) |
| 22 | { |
| 23 | Console.Error.WriteLine("Unknown command. Try: forge auth login"); |
| 24 | return 2; |
| 25 | } |
| 26 | |
| 27 | if (args.Length < 2) |
| 28 | { |
| 29 | PrintHelp(); |
| 30 | return 2; |
| 31 | } |
| 32 | |
| 33 | var host = ResolveHost(args); |
| 34 | var store = new ForgeCredentialStore(); |
| 35 | |
| 36 | return args[1].ToLowerInvariant() switch |
| 37 | { |
| 38 | "login" => await LoginAsync(host, store, args), |
| 39 | "approve" => await ApproveAsync(host, args), |
| 40 | "status" => Status(host, store), |
| 41 | "logout" => Logout(host, store), |
| 42 | "token" => Token(host, store), |
| 43 | _ => UnknownAuthCommand(), |
| 44 | }; |
| 45 | } |
| 46 | |
| 47 | private static async Task<int> LoginAsync(string host, ForgeCredentialStore store, string[] args) |
| 48 | { |
| 49 | var forceDevice = HasFlag(args, "--device"); |
| 50 | var oauthProvider = GetOption(args, "--oauth"); |
| 51 | if (!forceDevice && (oauthProvider is not null || await HasOAuthProvidersAsync(host).ConfigureAwait(false))) |
| 52 | { |
| 53 | var provider = oauthProvider ?? "github"; |
| 54 | var oauth = new ForgeOAuthLoopbackClient(); |
| 55 | var (ok, token, tokenName, error) = await oauth.ConnectAsync(host, provider).ConfigureAwait(false); |
| 56 | if (ok && !string.IsNullOrEmpty(token)) |
| 57 | { |
| 58 | store.Save(host, token, tokenName); |
| 59 | Console.WriteLine($"Logged in to {host} via OAuth ({tokenName ?? provider})."); |
| 60 | Console.WriteLine($"Credentials: {ForgeCredentialStore.GetDefaultFilePath()}"); |
| 61 | return 0; |
| 62 | } |
| 63 | |
| 64 | if (oauthProvider is not null) |
| 65 | { |
| 66 | Console.Error.WriteLine("OAuth login failed: " + error); |
| 67 | return 1; |
| 68 | } |
| 69 | |
| 70 | Console.WriteLine("OAuth unavailable, falling back to device login…"); |
| 71 | } |
| 72 | |
| 73 | return await DeviceLoginAsync(host, store, args).ConfigureAwait(false); |
| 74 | } |
| 75 | |
| 76 | private static async Task<bool> HasOAuthProvidersAsync(string host) |
| 77 | { |
| 78 | try |
| 79 | { |
| 80 | using var http = CreateHttpClient(host); |
| 81 | using var response = await http.GetAsync("/api/v1/auth/providers").ConfigureAwait(false); |
| 82 | if (!response.IsSuccessStatusCode) |
| 83 | return false; |
| 84 | using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync().ConfigureAwait(false)); |
| 85 | return doc.RootElement.TryGetProperty("providers", out var providers) |
| 86 | && providers.GetArrayLength() > 0; |
| 87 | } |
| 88 | catch |
| 89 | { |
| 90 | return false; |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | private static async Task<int> DeviceLoginAsync(string host, ForgeCredentialStore store, string[] args) |
| 95 | { |
| 96 | var scopes = GetOption(args, "--scopes") ?? "read,write,accept_merge"; |
| 97 | using var http = CreateHttpClient(host); |
| 98 | |
| 99 | HttpResponseMessage beginResponse; |
| 100 | try |
| 101 | { |
| 102 | beginResponse = await http.PostAsJsonAsync( |
| 103 | "/api/v1/auth/device", |
| 104 | new { clientName = "forge-cli", scopes }, |
| 105 | JsonOptions); |
| 106 | } |
| 107 | catch (HttpRequestException ex) when (ex.InnerException is System.Net.Sockets.SocketException) |
| 108 | { |
| 109 | Console.Error.WriteLine($"Cannot reach Forge at {host}. Start the server first."); |
| 110 | Console.Error.WriteLine(" dotnet run --project src/AgentForge/AgentForge.csproj"); |
| 111 | return 1; |
| 112 | } |
| 113 | |
| 114 | using (beginResponse) |
| 115 | { |
| 116 | if (!beginResponse.IsSuccessStatusCode) |
| 117 | { |
| 118 | await WriteApiErrorAsync(beginResponse); |
| 119 | return 1; |
| 120 | } |
| 121 | |
| 122 | using var beginDoc = JsonDocument.Parse(await beginResponse.Content.ReadAsStringAsync()); |
| 123 | var root = beginDoc.RootElement; |
| 124 | var deviceCode = root.GetProperty("device_code").GetString()!; |
| 125 | var userCode = root.GetProperty("user_code").GetString()!; |
| 126 | var verificationUri = root.GetProperty("verification_uri").GetString()!; |
| 127 | var interval = root.TryGetProperty("interval", out var intervalElement) ? intervalElement.GetInt32() : 5; |
| 128 | var expiresIn = root.TryGetProperty("expires_in", out var expiresElement) ? expiresElement.GetInt32() : 900; |
| 129 | |
| 130 | Console.WriteLine($"Open: {verificationUri}"); |
| 131 | Console.WriteLine($"User code: {userCode}"); |
| 132 | Console.WriteLine("Waiting for approval (bootstrap on server, or: forge auth approve " + userCode + ")…"); |
| 133 | |
| 134 | var deadline = DateTimeOffset.UtcNow.AddSeconds(expiresIn); |
| 135 | while (DateTimeOffset.UtcNow < deadline) |
| 136 | { |
| 137 | await Task.Delay(TimeSpan.FromSeconds(Math.Max(1, interval))); |
| 138 | |
| 139 | HttpResponseMessage pollResponse; |
| 140 | try |
| 141 | { |
| 142 | pollResponse = await http.PostAsJsonAsync( |
| 143 | "/api/v1/auth/device/token", |
| 144 | new { deviceCode }, |
| 145 | JsonOptions); |
| 146 | } |
| 147 | catch (HttpRequestException ex) when (ex.InnerException is System.Net.Sockets.SocketException) |
| 148 | { |
| 149 | Console.Error.WriteLine($"Lost connection to Forge at {host}. Keep the server running until login completes."); |
| 150 | Console.Error.WriteLine("Start: dotnet run --project src/AgentForge/AgentForge.csproj"); |
| 151 | return 1; |
| 152 | } |
| 153 | |
| 154 | using (pollResponse) |
| 155 | { |
| 156 | if (pollResponse.IsSuccessStatusCode) |
| 157 | { |
| 158 | using var pollDoc = JsonDocument.Parse(await pollResponse.Content.ReadAsStringAsync()); |
| 159 | var token = pollDoc.RootElement.GetProperty("access_token").GetString()!; |
| 160 | var tokenName = pollDoc.RootElement.TryGetProperty("token_name", out var nameElement) |
| 161 | ? nameElement.GetString() |
| 162 | : null; |
| 163 | store.Save(host, token, tokenName); |
| 164 | Console.WriteLine($"Logged in to {host} as {tokenName ?? "forge-cli"}."); |
| 165 | Console.WriteLine($"Credentials: {ForgeCredentialStore.GetDefaultFilePath()}"); |
| 166 | return 0; |
| 167 | } |
| 168 | |
| 169 | using var errorDoc = JsonDocument.Parse(await pollResponse.Content.ReadAsStringAsync()); |
| 170 | var error = errorDoc.RootElement.TryGetProperty("error", out var errorElement) |
| 171 | ? errorElement.GetString() |
| 172 | : null; |
| 173 | if (error is not ("authorization_pending" or "slow_down")) |
| 174 | { |
| 175 | Console.Error.WriteLine($"Login failed: {error ?? pollResponse.ReasonPhrase}"); |
| 176 | return 1; |
| 177 | } |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | Console.Error.WriteLine("Device login expired."); |
| 182 | return 1; |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | private static async Task<int> ApproveAsync(string host, string[] args) |
| 187 | { |
| 188 | var userCode = args.ElementAtOrDefault(2); |
| 189 | if (string.IsNullOrWhiteSpace(userCode)) |
| 190 | { |
| 191 | Console.Error.WriteLine("Usage: forge auth approve <user-code> [--host URL]"); |
| 192 | return 2; |
| 193 | } |
| 194 | |
| 195 | var bearer = Environment.GetEnvironmentVariable("FORGE_BOOTSTRAP_TOKEN")?.Trim(); |
| 196 | if (string.IsNullOrEmpty(bearer)) |
| 197 | { |
| 198 | Console.Error.Write("Bootstrap or API token: "); |
| 199 | bearer = ReadSecretLine(); |
| 200 | } |
| 201 | |
| 202 | using var http = CreateHttpClient(host); |
| 203 | using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/auth/device/approve") |
| 204 | { |
| 205 | Content = JsonContent.Create(new { userCode }), |
| 206 | }; |
| 207 | request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer); |
| 208 | |
| 209 | using var response = await http.SendAsync(request); |
| 210 | if (!response.IsSuccessStatusCode) |
| 211 | { |
| 212 | await WriteApiErrorAsync(response); |
| 213 | return 1; |
| 214 | } |
| 215 | |
| 216 | using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); |
| 217 | var tokenName = doc.RootElement.GetProperty("tokenName").GetString(); |
| 218 | Console.WriteLine($"Approved device login for {tokenName}."); |
| 219 | return 0; |
| 220 | } |
| 221 | |
| 222 | private static int Status(string host, ForgeCredentialStore store) |
| 223 | { |
| 224 | var entry = store.TryGet(host); |
| 225 | if (entry is null) |
| 226 | { |
| 227 | Console.WriteLine($"Not logged in to {host}."); |
| 228 | return 1; |
| 229 | } |
| 230 | |
| 231 | Console.WriteLine($"Host: {ForgeHostUrl.NormalizeKey(host)}"); |
| 232 | Console.WriteLine($"Token name: {entry.TokenName ?? "(unknown)"}"); |
| 233 | Console.WriteLine($"Saved at: {entry.SavedAt:u}"); |
| 234 | return 0; |
| 235 | } |
| 236 | |
| 237 | private static int Logout(string host, ForgeCredentialStore store) |
| 238 | { |
| 239 | if (!store.Remove(host)) |
| 240 | { |
| 241 | Console.WriteLine($"No credentials for {host}."); |
| 242 | return 1; |
| 243 | } |
| 244 | |
| 245 | Console.WriteLine($"Removed credentials for {host}."); |
| 246 | return 0; |
| 247 | } |
| 248 | |
| 249 | private static int Token(string host, ForgeCredentialStore store) |
| 250 | { |
| 251 | var token = store.TryGetToken(host); |
| 252 | if (string.IsNullOrEmpty(token)) |
| 253 | { |
| 254 | Console.Error.WriteLine($"Not logged in to {host}. Run: forge auth login"); |
| 255 | return 1; |
| 256 | } |
| 257 | |
| 258 | Console.WriteLine(token); |
| 259 | return 0; |
| 260 | } |
| 261 | |
| 262 | private static HttpClient CreateHttpClient(string host) => |
| 263 | new() { BaseAddress = new Uri(host.TrimEnd('/') + "/") }; |
| 264 | |
| 265 | private static string ResolveHost(string[] args) => |
| 266 | GetOption(args, "--host") |
| 267 | ?? Environment.GetEnvironmentVariable("FORGE_BASE_URL")?.Trim() |
| 268 | ?? DefaultHost; |
| 269 | |
| 270 | private static bool HasFlag(string[] args, string name) => |
| 271 | args.Any(arg => string.Equals(arg, name, StringComparison.OrdinalIgnoreCase)); |
| 272 | |
| 273 | private static string? GetOption(string[] args, string name) |
| 274 | { |
| 275 | for (var i = 0; i < args.Length - 1; i++) |
| 276 | { |
| 277 | if (string.Equals(args[i], name, StringComparison.OrdinalIgnoreCase)) |
| 278 | return args[i + 1]; |
| 279 | } |
| 280 | |
| 281 | return null; |
| 282 | } |
| 283 | |
| 284 | private static async Task WriteApiErrorAsync(HttpResponseMessage response) |
| 285 | { |
| 286 | var body = await response.Content.ReadAsStringAsync(); |
| 287 | try |
| 288 | { |
| 289 | using var doc = JsonDocument.Parse(body); |
| 290 | var detail = doc.RootElement.TryGetProperty("detail", out var detailElement) |
| 291 | ? detailElement.GetString() |
| 292 | : doc.RootElement.TryGetProperty("error", out var errorElement) |
| 293 | ? errorElement.GetString() |
| 294 | : body; |
| 295 | Console.Error.WriteLine(detail ?? response.ReasonPhrase); |
| 296 | return; |
| 297 | } |
| 298 | catch |
| 299 | { |
| 300 | Console.Error.WriteLine(string.IsNullOrWhiteSpace(body) ? response.ReasonPhrase : body); |
| 301 | } |
| 302 | } |
| 303 | |
| 304 | private static string ReadSecretLine() |
| 305 | { |
| 306 | var line = Console.ReadLine(); |
| 307 | return line?.Trim() ?? ""; |
| 308 | } |
| 309 | |
| 310 | private static int UnknownAuthCommand() |
| 311 | { |
| 312 | PrintHelp(); |
| 313 | return 2; |
| 314 | } |
| 315 | |
| 316 | private static void PrintHelp() |
| 317 | { |
| 318 | Console.WriteLine(""" |
| 319 | forge — Agent Forge CLI |
| 320 | |
| 321 | Commands: |
| 322 | forge auth login [--host URL] [--oauth github] [--device] [--scopes …] |
| 323 | forge auth approve <user-code> [--host URL] |
| 324 | forge auth status [--host URL] |
| 325 | forge auth logout [--host URL] |
| 326 | forge auth token [--host URL] |
| 327 | |
| 328 | Credentials file: ~/.forge/credentials.json |
| 329 | Env: FORGE_BASE_URL, FORGE_BOOTSTRAP_TOKEN (approve only) |
| 330 | """); |
| 331 | } |
| 332 | } |
| 333 | |