using System.Diagnostics; using System.Globalization; using System.Text; using System.Text.Json; namespace XFE.SeAgent.Cli; public sealed class CliApplication(EndpointDiscovery? discovery = null, AgentPipeClient? client = null) { private readonly EndpointDiscovery discovery = discovery ?? new(); private readonly AgentPipeClient client = client ?? new(); private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); public async Task RunAsync(string[] args, TextWriter output, TextWriter error, CancellationToken token = default) { try { if (args.Length == 0 || args[0] is "help" or "--help" or "-h") { await WriteAsync(output, new { name = "xfe-se", protocolVersion = 1, commands = new[] { "discover [--pid PID]", "call METHOD [--params JSON|@FILE] [--pipe NAME|--pid PID] [--timeout 30]", "deploy --block ID --file SCRIPT.CS --expected-hash SHA256 [--pipe NAME|--pid PID]", "watch --block ID,ID --seconds 30 --interval 1 --out telemetry.jsonl [--pipe NAME|--pid PID]" }, note = "stdout 为 JSON,诊断写入 stderr;多个游戏运行时必须选择 --pipe 或 --pid。" }); return 0; } var arguments = Parse(args); if (arguments.Command == "discover") { var endpoints = discovery.Discover(arguments.Value("--endpoint-directory")); if (arguments.Value("--pid") is string pid) { int selectedPid = PositiveInt(pid, "PID"); endpoints = endpoints.Where(item => item.Pid == selectedPid).ToArray(); } await WriteAsync(output, new { endpoints }); return 0; } string pipe = ResolvePipe(arguments); int timeout = checked((int)Math.Round(Number(arguments.Value("--timeout") ?? "30", "请求超时", .001, 30) * 1000)); string method; JsonElement parameters; if (arguments.Command == "call") { method = arguments.Method!; string json = arguments.Value("--params") ?? "{}"; if (json.StartsWith('@')) json = await ReadBoundedTextAsync(json[1..], token); using var document = JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = 64 }); if (document.RootElement.ValueKind != JsonValueKind.Object) throw new CliUsageException("--params 必须是 JSON 对象。"); parameters = document.RootElement.Clone(); } else if (arguments.Command == "deploy") { method = "pb.deploy"; string id = EntityId(arguments.Required("--block")), hash = arguments.Required("--expected-hash"); if (hash.Length != 64 || !hash.All(Uri.IsHexDigit)) throw new CliUsageException("--expected-hash 必须是 64 位 SHA-256;先用 pb.read 获取当前脚本哈希。"); string source = await ReadBoundedTextAsync(arguments.Required("--file"), token); parameters = JsonSerializer.SerializeToElement(new { entityId = id, source, expectedSha256 = hash.ToLowerInvariant() }); } else return await WatchAsync(arguments, pipe, timeout, output, error, token); var response = await client.CallAsync(pipe, method, parameters, timeout, token); await WriteAsync(output, response.Envelope); if (response.IsError) { await error.WriteLineAsync(response.Envelope.GetProperty("error").GetRawText()); return 4; } return 0; } catch (OperationCanceledException) { await FailureAsync(output, error, "cancelled", "客户端等待已取消。"); return 130; } catch (CliUsageException ex) { await FailureAsync(output, error, "invalid_arguments", ex.Message); return 2; } catch (JsonException ex) { await FailureAsync(output, error, "invalid_json", "输入 JSON 无效:" + ex.Message); return 2; } catch (AgentClientException ex) { await FailureAsync(output, error, ex.Code, ex.Message); return 3; } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or OverflowException) { await FailureAsync(output, error, "local_error", ex.Message); return 1; } } private async Task WatchAsync(Arguments arguments, string pipe, int timeout, TextWriter output, TextWriter error, CancellationToken token) { string[] ids = arguments.Required("--block").Split(',').Select(EntityId).Distinct(StringComparer.Ordinal).ToArray(); double seconds = Number(arguments.Value("--seconds") ?? "30", "采样时长", .05, 86400); double interval = Number(arguments.Value("--interval") ?? "1", "采样间隔", .05, 60); string path = Path.GetFullPath(arguments.Required("--out")); Directory.CreateDirectory(Path.GetDirectoryName(path)!); await using var file = new StreamWriter(new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read), new UTF8Encoding(false)) { NewLine = "\n" }; var parameters = JsonSerializer.SerializeToElement(new { entityIds = ids }); var clock = Stopwatch.StartNew(); int samples = 0; do { token.ThrowIfCancellationRequested(); var response = await client.CallAsync(pipe, "telemetry.snapshot", parameters, timeout, token); await file.WriteLineAsync(JsonSerializer.Serialize(new { capturedUtc = DateTimeOffset.UtcNow, response = response.Envelope }, Json)); await file.FlushAsync(token); samples++; if (response.IsError) { await WriteAsync(output, new { samples, outputPath = path, error = response.Envelope.GetProperty("error") }); await error.WriteLineAsync(response.Envelope.GetProperty("error").GetRawText()); return 4; } double remaining = seconds - clock.Elapsed.TotalSeconds; if (remaining <= 0) break; await Task.Delay(TimeSpan.FromSeconds(Math.Min(interval, remaining)), token); } while (clock.Elapsed.TotalSeconds < seconds); await WriteAsync(output, new { samples, elapsedSeconds = clock.Elapsed.TotalSeconds, outputPath = path }); return 0; } private string ResolvePipe(Arguments arguments) { if (arguments.Value("--pipe") is string pipe) { if (arguments.Value("--pid") != null) throw new CliUsageException("--pipe 与 --pid 只能选一个。"); return pipe; } var endpoints = discovery.Discover(arguments.Value("--endpoint-directory")); if (arguments.Value("--pid") is string pid) { int selectedPid = PositiveInt(pid, "PID"); endpoints = endpoints.Where(item => item.Pid == selectedPid).ToArray(); } if (endpoints.Count == 0) throw new CliUsageException("未发现匹配的活动 Agent;请确认游戏中已加载插件,或明确指定 --pipe。"); if (endpoints.Count > 1) throw new CliUsageException("多个游戏 Agent 正在运行,请使用 --pid 或 --pipe 选择一个。"); return endpoints[0].PipeName; } private static async Task ReadBoundedTextAsync(string path, CancellationToken token) { if (new FileInfo(path).Length > AgentPipeClient.MaximumRequestBytes) throw new CliUsageException("输入文件超过 1 MiB 请求限制。"); string content = await File.ReadAllTextAsync(path, token); if (Encoding.UTF8.GetByteCount(content) > AgentPipeClient.MaximumRequestBytes) throw new CliUsageException("输入文件超过 1 MiB 请求限制。"); return content; } private static async Task WriteAsync(TextWriter writer, object value) => await writer.WriteLineAsync(JsonSerializer.Serialize(value, Json)); private static async Task FailureAsync(TextWriter output, TextWriter error, string code, string message) { await WriteAsync(output, new { error = new { code, message } }); await error.WriteLineAsync(message); } private static int PositiveInt(string value, string name) => int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out int number) && number > 0 ? number : throw new CliUsageException(name + " 必须是正整数。"); private static string EntityId(string value) => long.TryParse(value.Trim(), NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out long id) && id != 0 ? id.ToString(CultureInfo.InvariantCulture) : throw new CliUsageException("方块 ID 必须是非零 Int64 十进制整数。"); private static double Number(string text, string name, double min, double max) => double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out double value) && double.IsFinite(value) && value >= min && value <= max ? value : throw new CliUsageException($"{name}必须在 {min} 至 {max} 秒之间。"); private static Arguments Parse(string[] args) { string command = args[0]; if (command is not ("discover" or "call" or "deploy" or "watch")) throw new CliUsageException("未知命令;使用 --help 查看用法。"); int position = 1; string? method = null; if (command == "call") { if (args.Length < 2 || args[1].StartsWith("--")) throw new CliUsageException("call 需要方法名。"); method = args[position++]; } string[] specific = command switch { "call" => ["--params"], "deploy" => ["--block", "--file", "--expected-hash"], "watch" => ["--block", "--seconds", "--interval", "--out"], _ => [] }; var allowed = specific.Concat(command == "discover" ? ["--pid", "--endpoint-directory"] : new[] { "--pipe", "--pid", "--timeout", "--endpoint-directory" }).ToHashSet(StringComparer.Ordinal); var options = new Dictionary(StringComparer.Ordinal); while (position < args.Length) { string option = args[position++]; if (!allowed.Contains(option) || position == args.Length) throw new CliUsageException("未知选项或缺少参数值:" + option); if (!options.TryAdd(option, args[position++])) throw new CliUsageException("选项重复:" + option); } return new(command, method, options); } private sealed record Arguments(string Command, string? Method, Dictionary Options) { public string? Value(string key) => Options.GetValueOrDefault(key); public string Required(string key) => Value(key) is string value && value.Length > 0 ? value : throw new CliUsageException("缺少必填选项:" + key); } private sealed class CliUsageException(string message) : Exception(message); }