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, OfflineWorldStore? worlds = null, IWorldUseGuard? host = null)
{
private readonly EndpointDiscovery discovery = discovery ?? new();
private readonly AgentPipeClient client = client ?? new();
private readonly OfflineWorldStore worlds = worlds ?? new();
private readonly IWorldUseGuard host = host ?? new HostStatus(discovery);
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
public async Task<int> 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]",
"host status",
"world list [--root SAVE_ROOT]", "world inspect --path WORLD", "world read --path WORLD --block ID",
"world backup --path WORLD [--out NEW_DIRECTORY]",
"world deploy --manifest FILE [--apply true|false]",
"world restore --backup DEPLOYMENT_BACKUP" },
note = "stdout 为 JSON,诊断写入 stderr;多个游戏运行时必须选择 --pipe 或 --pid。" });
return 0;
}
var arguments = Parse(args);
if (arguments.Command == "host")
{ await WriteAsync(output, new { hosts = host.Inspect() }); return 0; }
if (arguments.Command == "world")
{
object result = arguments.Method switch
{
"list" => worlds.List(arguments.Value("--root")),
"inspect" => worlds.Inspect(arguments.Required("--path")),
"read" => worlds.ReadProgram(arguments.Required("--path"), arguments.Required("--block")),
"backup" => worlds.Backup(arguments.Required("--path"), arguments.Value("--out")),
"deploy" => worlds.Deploy(arguments.Required("--manifest"), Boolean(arguments.Value("--apply") ?? "false")),
"restore" => worlds.Restore(arguments.Required("--backup")),
_ => throw new CliUsageException("未知 world 操作。")
};
await WriteAsync(output, result); return 0;
}
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 (OfflineWorldException ex)
{ await FailureAsync(output, error, ex.Code, ex.Message); return 5; }
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or OverflowException or System.Xml.XmlException)
{ await FailureAsync(output, error, "local_error", ex.Message); return 1; }
}
private async Task<int> 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<string> 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 bool Boolean(string value) => bool.TryParse(value, out bool result) ? result : throw new CliUsageException("--apply 必须是 true 或 false。");
private static Arguments Parse(string[] args)
{
string command = args[0];
if (command is not ("discover" or "call" or "deploy" or "watch" or "host" or "world")) throw new CliUsageException("未知命令;使用 --help 查看用法。");
int position = 1; string? method = null;
if (command is "call" or "world" or "host") { if (args.Length < 2 || args[1].StartsWith("--")) throw new CliUsageException(command + " 需要方法名。"); method = args[position++]; }
string[] specific = command switch { "call" => ["--params"], "deploy" => ["--block", "--file", "--expected-hash"], "watch" => ["--block", "--seconds", "--interval", "--out"], _ => [] };
string[] local = command == "host" && method == "status" ? [] : command == "world" ? method switch
{
"list" => ["--root"], "inspect" => ["--path"], "read" => ["--path", "--block"],
"backup" => ["--path", "--out"], "deploy" => ["--manifest", "--apply"], "restore" => ["--backup"],
_ => throw new CliUsageException("未知 world 操作。")
} : command == "host" ? throw new CliUsageException("未知 host 操作。") : [];
var allowed = (command is "host" or "world" ? local : specific.Concat(command == "discover" ? ["--pid", "--endpoint-directory"] : new[] { "--pipe", "--pid", "--timeout", "--endpoint-directory" })).ToHashSet(StringComparer.Ordinal);
var options = new Dictionary<string, string>(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<string, string> 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);
}
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, OfflineWorldStore? worlds = null, IWorldUseGuard? host = null)
{
private readonly EndpointDiscovery discovery = discovery ?? new();
private readonly AgentPipeClient client = client ?? new();
private readonly OfflineWorldStore worlds = worlds ?? new();
private readonly IWorldUseGuard host = host ?? new HostStatus(discovery);
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
public async Task<int> 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]",
"host status",
"world list [--root SAVE_ROOT]", "world inspect --path WORLD", "world read --path WORLD --block ID",
"world backup --path WORLD [--out NEW_DIRECTORY]",
"world deploy --manifest FILE [--apply true|false]",
"world restore --backup DEPLOYMENT_BACKUP" },
note = "stdout 为 JSON,诊断写入 stderr;多个游戏运行时必须选择 --pipe 或 --pid。" });
return 0;
}
var arguments = Parse(args);
if (arguments.Command == "host")
{ await WriteAsync(output, new { hosts = host.Inspect() }); return 0; }
if (arguments.Command == "world")
{
object result = arguments.Method switch
{
"list" => worlds.List(arguments.Value("--root")),
"inspect" => worlds.Inspect(arguments.Required("--path")),
"read" => worlds.ReadProgram(arguments.Required("--path"), arguments.Required("--block")),
"backup" => worlds.Backup(arguments.Required("--path"), arguments.Value("--out")),
"deploy" => worlds.Deploy(arguments.Required("--manifest"), Boolean(arguments.Value("--apply") ?? "false")),
"restore" => worlds.Restore(arguments.Required("--backup")),
_ => throw new CliUsageException("未知 world 操作。")
};
await WriteAsync(output, result); return 0;
}
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 (OfflineWorldException ex)
{ await FailureAsync(output, error, ex.Code, ex.Message); return 5; }
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or OverflowException or System.Xml.XmlException)
{ await FailureAsync(output, error, "local_error", ex.Message); return 1; }
}
private async Task<int> 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<string> 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 bool Boolean(string value) => bool.TryParse(value, out bool result) ? result : throw new CliUsageException("--apply 必须是 true 或 false。");
private static Arguments Parse(string[] args)
{
string command = args[0];
if (command is not ("discover" or "call" or "deploy" or "watch" or "host" or "world")) throw new CliUsageException("未知命令;使用 --help 查看用法。");
int position = 1; string? method = null;
if (command is "call" or "world" or "host") { if (args.Length < 2 || args[1].StartsWith("--")) throw new CliUsageException(command + " 需要方法名。"); method = args[position++]; }
string[] specific = command switch { "call" => ["--params"], "deploy" => ["--block", "--file", "--expected-hash"], "watch" => ["--block", "--seconds", "--interval", "--out"], _ => [] };
string[] local = command == "host" && method == "status" ? [] : command == "world" ? method switch
{
"list" => ["--root"], "inspect" => ["--path"], "read" => ["--path", "--block"],
"backup" => ["--path", "--out"], "deploy" => ["--manifest", "--apply"], "restore" => ["--backup"],
_ => throw new CliUsageException("未知 world 操作。")
} : command == "host" ? throw new CliUsageException("未知 host 操作。") : [];
var allowed = (command is "host" or "world" ? local : specific.Concat(command == "discover" ? ["--pid", "--endpoint-directory"] : new[] { "--pipe", "--pid", "--timeout", "--endpoint-directory" })).ToHashSet(StringComparer.Ordinal);
var options = new Dictionary<string, string>(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<string, string> 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);
}