Forge
csharp3407750f
1using System.Net.Http.Headers;
2using System.Net.Http.Json;
3using System.Text.Json;
4using AgentForge.Credentials;
5
6return await ForgeCli.RunAsync(args);
7
8internal 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
View only · write via MCP/CIDE