Forge
csharp4405de34
1#nullable enable
2using System.Diagnostics.CodeAnalysis;
3using CascadeIDE.Features.UiChrome;
4using GitMcp.Core;
5
6namespace CascadeIDE.Services.AgentContract;
7
8/// <summary>
9/// Headless вывод JSON контракта агента в stdout (ADR 0052): те же сборщики, что и MCP <c>ide_*</c>, без stdio MCP.
10/// Запуск: <c>--agent-contract [--workspace &lt;dir&gt;] &lt;command&gt;</c> (см. <see cref="PrintHelp"/>).
11/// </summary>
12public static class AgentContractRunner
13{
14 private static readonly IGitCommandRunner GitRunner = new GitCommandRunner();
15
16 /// <summary>Возвращает код выхода: 0 — ок, 1 — help/нет команды, 2 — ошибка.</summary>
17 public static int Run(ReadOnlySpan<string> args)
18 {
19 var argv = args.Length == 0 ? Array.Empty<string>() : args.ToArray();
20 return RunAsync(argv).GetAwaiter().GetResult();
21 }
22
23 /// <summary>Тот же JSON, что вернёт MCP для соответствующей команды (для тестов и внешних вызовов).</summary>
24 public static string GetContractJson(string command)
25 {
26 if (!TryGetJson(new[] { command }, out var json, out var error) || json is null)
27 throw new InvalidOperationException(error ?? "Unknown command.");
28
29 return json;
30 }
31
32 /// <summary>Полный argv после <c>--agent-contract</c> (включая <c>--workspace</c> и хвост для git).</summary>
33 public static string GetContractJson(IReadOnlyList<string> argv)
34 {
35 var arr = argv is string[] a ? a : argv.ToArray();
36 if (!TryGetJson(arr, out var json, out var error) || json is null)
37 throw new InvalidOperationException(error ?? "Unknown command.");
38
39 return json;
40 }
41
42 public static bool TryGetJson(string[] args, [NotNullWhen(true)] out string? json, [NotNullWhen(false)] out string? error)
43 {
44 var r = TryGetJsonAsync(args, CancellationToken.None).GetAwaiter().GetResult();
45 json = r.Json;
46 error = r.Error;
47 return r.Ok;
48 }
49
50 public static async Task<(bool Ok, string? Json, string? Error)> TryGetJsonAsync(
51 string[] args,
52 CancellationToken cancellationToken = default)
53 {
54 if (args.Length == 0)
55 return (false, null, "No command.");
56
57 if (!TryParseWorkspaceAndPositionals(args, out var workspace, out var positionals, out var parseError))
58 return (false, null, parseError);
59
60 if (positionals.Length == 0)
61 return (false, null, "No command.");
62
63 var command = positionals[0];
64 if (IsHelp(command))
65 return (false, null, null); // Run maps (false, null, null) to PrintHelp + exit 0
66
67 var tail = positionals.Length > 1 ? positionals.AsSpan(1).ToArray() : Array.Empty<string>();
68
69 try
70 {
71 var json = await TryBuildJsonAsync(workspace, command, tail, cancellationToken).ConfigureAwait(false);
72 return (true, json, null);
73 }
74 catch (InvalidOperationException ex)
75 {
76 return (false, null, ex.Message);
77 }
78 }
79
80 private static async Task<string> TryBuildJsonAsync(
81 string? workspace,
82 string command,
83 string[] tail,
84 CancellationToken cancellationToken)
85 {
86 switch (command)
87 {
88 case IdeCommands.GetUiModesDiagnostics:
89 RequireEmptyTail(tail, command);
90 UiModeCatalog.Initialize();
91 return UiModeCatalog.GetDiagnosticsJson();
92
93 case IdeCommands.GetSupportedEditorLanguages:
94 RequireEmptyTail(tail, command);
95 return EditorLanguageSupport.GetJson();
96
97 case IdeCommands.GitStatus:
98 RequireEmptyTail(tail, command);
99 return await GitStatusAsync(workspace, cancellationToken).ConfigureAwait(false);
100
101 case IdeCommands.GitDiff:
102 return await GitDiffAsync(workspace, tail, cancellationToken).ConfigureAwait(false);
103
104 case IdeCommands.GitLog:
105 return await GitLogAsync(workspace, tail, cancellationToken).ConfigureAwait(false);
106
107 case IdeCommands.GitBranch:
108 return await GitBranchAsync(workspace, tail, cancellationToken).ConfigureAwait(false);
109
110 case IdeCommands.GitShow:
111 return await GitShowAsync(workspace, tail, cancellationToken).ConfigureAwait(false);
112
113 case IdeCommands.GetSolutionInfo:
114 RequireEmptyTail(tail, command);
115 return AgentContractHeadlessRuntime.GetSolutionInfoJson();
116
117 case IdeCommands.GetCockpitSurface:
118 RequireEmptyTail(tail, command);
119 return AgentContractHeadlessRuntime.GetCockpitSurfaceJson();
120
121 case IdeCommands.GetIdeState:
122 RequireEmptyTail(tail, command);
123 return AgentContractHeadlessRuntime.GetIdeStateJson();
124
125 default:
126 throw new InvalidOperationException(
127 $"Unknown agent contract command: {command}. See --agent-contract --help.");
128 }
129 }
130
131 private static void RequireEmptyTail(string[] tail, string command)
132 {
133 if (tail.Length > 0)
134 throw new InvalidOperationException($"{command}: unexpected arguments after command.");
135 }
136
137 private static string ResolveWorkspaceRoot(string? workspace)
138 {
139 var root = string.IsNullOrWhiteSpace(workspace) ? Environment.CurrentDirectory : workspace.Trim();
140 if (!Directory.Exists(root))
141 throw new InvalidOperationException($"Workspace path does not exist: {root}");
142 return root;
143 }
144
145 private static async Task<string> GitStatusAsync(string? workspace, CancellationToken cancellationToken)
146 {
147 var root = ResolveWorkspaceRoot(workspace);
148 return await AgentContractGitJson.RunAsync(
149 GitRunner,
150 GitCommandBuilder.StatusShortBranch(),
151 root,
152 cancellationToken).ConfigureAwait(false);
153 }
154
155 private static async Task<string> GitDiffAsync(string? workspace, string[] tail, CancellationToken cancellationToken)
156 {
157 if (!TryParseGitDiffTail(tail, out var path, out var staged, out var err))
158 throw new InvalidOperationException(err);
159 var root = ResolveWorkspaceRoot(workspace);
160 return await AgentContractGitJson.RunAsync(
161 GitRunner,
162 GitCommandBuilder.Diff(staged, path),
163 root,
164 cancellationToken).ConfigureAwait(false);
165 }
166
167 private static async Task<string> GitLogAsync(string? workspace, string[] tail, CancellationToken cancellationToken)
168 {
169 if (!TryParseGitLogTail(tail, out var n, out var err))
170 throw new InvalidOperationException(err);
171 var root = ResolveWorkspaceRoot(workspace);
172 return await AgentContractGitJson.RunAsync(
173 GitRunner,
174 GitCommandBuilder.Log(n),
175 root,
176 cancellationToken).ConfigureAwait(false);
177 }
178
179 private static async Task<string> GitBranchAsync(string? workspace, string[] tail, CancellationToken cancellationToken)
180 {
181 if (tail.Length > 0)
182 throw new InvalidOperationException("git_branch list only: remove extra arguments.");
183 var root = ResolveWorkspaceRoot(workspace);
184 var r = GitCommandBuilder.BranchList();
185 if (!r.IsSuccess || r.Args is null)
186 throw new InvalidOperationException(r.Error ?? "git_branch: invalid args.");
187 return await AgentContractGitJson.RunAsync(GitRunner, r.Args, root, cancellationToken).ConfigureAwait(false);
188 }
189
190 private static async Task<string> GitShowAsync(string? workspace, string[] tail, CancellationToken cancellationToken)
191 {
192 if (!TryParseGitShowTail(tail, out var rev, out var path, out var statOnly, out var err))
193 throw new InvalidOperationException(err);
194 var root = ResolveWorkspaceRoot(workspace);
195 var r = GitCommandBuilder.Show(rev, path, statOnly);
196 if (!r.IsSuccess || r.Args is null)
197 throw new InvalidOperationException(r.Error ?? "git_show: invalid args.");
198 return await AgentContractGitJson.RunAsync(GitRunner, r.Args, root, cancellationToken).ConfigureAwait(false);
199 }
200
201 private static bool TryParseGitDiffTail(string[] tail, out string? path, out bool staged, [NotNullWhen(false)] out string? error)
202 {
203 path = null;
204 staged = false;
205 error = null;
206 for (var i = 0; i < tail.Length; i++)
207 {
208 var t = tail[i];
209 if (string.Equals(t, "--staged", StringComparison.OrdinalIgnoreCase))
210 {
211 staged = true;
212 continue;
213 }
214
215 if (string.Equals(t, "--path", StringComparison.OrdinalIgnoreCase))
216 {
217 if (i + 1 >= tail.Length)
218 {
219 error = "--path requires a value.";
220 return false;
221 }
222
223 path = tail[++i];
224 continue;
225 }
226
227 error = $"Unexpected argument for git_diff: {t}";
228 return false;
229 }
230
231 return true;
232 }
233
234 private static bool TryParseGitLogTail(string[] tail, out int n, [NotNullWhen(false)] out string? error)
235 {
236 n = GitCommandBuilder.LogCountDefault;
237 error = null;
238 for (var i = 0; i < tail.Length; i++)
239 {
240 if (string.Equals(tail[i], "--n", StringComparison.OrdinalIgnoreCase)
241 || string.Equals(tail[i], "-n", StringComparison.OrdinalIgnoreCase))
242 {
243 if (i + 1 >= tail.Length || !int.TryParse(tail[i + 1], out var v)) { error = "--n requires an integer."; return false; }
244 n = v;
245 i++;
246 continue;
247 }
248
249 error = $"Unexpected argument for git_log: {tail[i]}";
250 return false;
251 }
252
253 return true;
254 }
255
256 private static bool TryParseGitShowTail(
257 string[] tail,
258 [NotNullWhen(true)] out string? rev,
259 out string? path,
260 out bool statOnly,
261 [NotNullWhen(false)] out string? error)
262 {
263 rev = null;
264 path = null;
265 statOnly = false;
266 error = null;
267 for (var i = 0; i < tail.Length; i++)
268 {
269 var t = tail[i];
270 if (string.Equals(t, "--stat-only", StringComparison.OrdinalIgnoreCase))
271 {
272 statOnly = true;
273 continue;
274 }
275
276 if (string.Equals(t, "--rev", StringComparison.OrdinalIgnoreCase))
277 {
278 if (i + 1 >= tail.Length)
279 {
280 error = "--rev requires a value.";
281 return false;
282 }
283
284 rev = tail[++i];
285 continue;
286 }
287
288 if (string.Equals(t, "--path", StringComparison.OrdinalIgnoreCase))
289 {
290 if (i + 1 >= tail.Length)
291 {
292 error = "--path requires a value.";
293 return false;
294 }
295
296 path = tail[++i];
297 continue;
298 }
299
300 error = $"Unexpected argument for git_show: {t}";
301 return false;
302 }
303
304 if (string.IsNullOrWhiteSpace(rev))
305 {
306 error = "git_show requires --rev <rev>.";
307 return false;
308 }
309
310 return true;
311 }
312
313 /// <summary>Только <c>--workspace</c> на верхнем уровне; далее команда и хвост (в т.ч. <c>--n</c>, <c>--path</c>).</summary>
314 private static bool TryParseWorkspaceAndPositionals(
315 string[] args,
316 out string? workspace,
317 [NotNullWhen(true)] out string[]? positionals,
318 [NotNullWhen(false)] out string? error)
319 {
320 workspace = null;
321 positionals = null;
322 error = null;
323 var rest = new List<string>();
324 for (var i = 0; i < args.Length; i++)
325 {
326 var a = args[i];
327 if (string.Equals(a, "--workspace", StringComparison.OrdinalIgnoreCase))
328 {
329 if (i + 1 >= args.Length)
330 {
331 error = "--workspace requires a path.";
332 return false;
333 }
334
335 workspace = args[++i];
336 continue;
337 }
338
339 if (a.StartsWith("--workspace=", StringComparison.OrdinalIgnoreCase))
340 {
341 var v = a["--workspace=".Length..];
342 if (string.IsNullOrEmpty(v))
343 {
344 error = "--workspace= requires a value.";
345 return false;
346 }
347
348 workspace = v;
349 continue;
350 }
351
352 rest.Add(a);
353 }
354
355 positionals = rest.ToArray();
356 return true;
357 }
358
359 private static bool IsHelp(string arg) =>
360 string.Equals(arg, "-h", StringComparison.OrdinalIgnoreCase)
361 || string.Equals(arg, "--help", StringComparison.OrdinalIgnoreCase);
362
363 private static void PrintHelp()
364 {
365 Console.Out.WriteLine(
366 """
367 CascadeIDE --agent-contract [--workspace <dir>] <command>
368 Print the same JSON as the matching ide_* MCP tool (ADR 0052). No GUI; no MCP stdio.
369 --workspace <dir> Root for git_* commands (default: current directory).
370
371 Commands (no workspace):
372 get_supported_editor_languages Same payload as ide_get_supported_editor_languages
373 get_ui_modes_diagnostics Same payload as ide_get_ui_modes_diagnostics
374 get_solution_info Same payload as ide_get_solution_info (headless VM)
375 get_cockpit_surface CDS only (CockpitSurfaceState), same as cockpit_surface in ide_get_ide_state
376 get_ide_state Same payload as ide_get_ide_state (headless VM; slower)
377
378 Commands (git — same JSON as ide_git_*):
379 git_status
380 git_diff [--path <file>] [--staged]
381 git_log [--n <count>]
382 git_branch (list only)
383 git_show --rev <rev> [--path <file>] [--stat-only]
384
385 Examples:
386 CascadeIDE.exe --agent-contract get_ui_modes_diagnostics
387 CascadeIDE.exe --agent-contract --workspace D:\repo git_status
388 CI: dotnet script docs/samples/agent-contract-ci.csx -- --exe path\to\CascadeIDE.exe
389 Or PowerShell: docs/samples/agent-contract-ci.ps1
390 """);
391 }
392
393 private static async Task<int> RunAsync(string[] argv)
394 {
395 if (argv.Length == 0)
396 {
397 PrintHelp();
398 return 1;
399 }
400
401 if (IsHelp(argv[0]))
402 {
403 PrintHelp();
404 return 0;
405 }
406
407 var r = await TryGetJsonAsync(argv, CancellationToken.None).ConfigureAwait(false);
408 if (!r.Ok)
409 {
410 if (r.Error is null)
411 {
412 PrintHelp();
413 return 0;
414 }
415
416 Console.Error.WriteLine(r.Error);
417 return 2;
418 }
419
420 Console.Out.WriteLine(r.Json);
421 return 0;
422 }
423}
424
View only · write via MCP/CIDE