// brokkr-command: a caller. It writes one Unity command intent into the project's directory store and reads the // receipt the editor service answers with. It never writes receipts, host snapshots or the agent-commands flag, // and it never opens the store as a single file: the editor owns execution and its own state. using System.Globalization; using GameCult.Brokkr; using GameCult.Caching; using GameCult.Caching.MessagePack; try { return await Cli.RunAsync(args); } catch (Exception error) when (error is ArgumentException or TimeoutException or InvalidOperationException) { Console.Error.WriteLine(error.Message); return 2; } internal static class Cli { // Every writable field of brokkr.unity.command_intent.v0 except the schema tag, the id and the action, which // have their own options. private static readonly Dictionary> Fields = new(StringComparer.Ordinal) { ["--target-object-id"] = (command, value) => command.targetObjectId = value, ["--name"] = (command, value) => command.name = value, ["--component-type"] = (command, value) => command.componentType = value, ["--property-path"] = (command, value) => command.propertyPath = value, ["--value"] = (command, value) => command.value = value, ["--asset-path"] = (command, value) => command.assetPath = value, ["--parent-object-id"] = (command, value) => command.parentObjectId = value, ["--local-position"] = (command, value) => command.localPosition = value, ["--local-euler-angles"] = (command, value) => command.localEulerAngles = value, ["--local-scale"] = (command, value) => command.localScale = value, ["--view"] = (command, value) => command.viewKind = value, ["--output"] = (command, value) => command.outputPath = value, ["--width"] = (command, value) => command.width = Integer("--width", value), ["--height"] = (command, value) => command.height = Integer("--height", value), ["--requested-by"] = (command, value) => command.requestedBy = value }; internal static async Task RunAsync(string[] args) { var options = Parse(args); var cachePath = Path.GetFullPath(Required(options, "--unity-cache")); var action = Required(options, "--action"); var readHost = string.Equals(action, "readHost", StringComparison.Ordinal); using var cache = await CultCacheMessagePack.OpenAsync(cachePath, new CultCacheOpenOptions { UseDirectoryStore = true, ReadOnly = readHost }); return readHost ? await ReadHostAsync(cache) : await WriteIntentAsync(cache, action, options); } private static async Task ReadHostAsync(CultCache cache) { await cache.PullAllBackingStoresAsync(); var host = ReadHostSnapshot(cache) ?? throw new InvalidOperationException("Brokkr host snapshot 'unity/host/current' is unavailable."); Console.WriteLine($"project: {host.projectPath}"); Console.WriteLine($"observed: {host.observedAt}"); Console.WriteLine($"scene: {host.activeScenePath}"); Console.WriteLine($"agent-commands: {(host.agentCommandsEnabled ? "on" : "off")}"); Console.WriteLine($"state: playing={host.isPlaying} paused={host.isPaused} compiling={host.isCompiling} updating={host.isUpdating}"); Console.WriteLine($"objects: {host.sceneObjects.Length}"); foreach (var sceneObject in host.sceneObjects.OrderBy(item => item.path, StringComparer.Ordinal)) { var components = string.Join(", ", sceneObject.components.Select(component => component.typeName)); Console.WriteLine($"- {sceneObject.path} active={sceneObject.activeSelf} layer={sceneObject.layer} components=[{components}]"); foreach (var component in sceneObject.components) foreach (var property in component.properties.Where(property => !property.path.StartsWith("m_", StringComparison.Ordinal) && !string.IsNullOrWhiteSpace(property.value))) Console.WriteLine($" {component.typeName}.{property.path}={property.value}"); } return 0; } private static async Task WriteIntentAsync(CultCache cache, string action, Dictionary options) { var command = new BrokkrUnityCommand { // A command id is single-use, so every invocation is a new act with a new id. --command-id exists for // callers that need to name their own; reusing an answered id is a caller defect. commandId = options.TryGetValue("--command-id", out var commandId) && !string.IsNullOrWhiteSpace(commandId) ? commandId : Guid.NewGuid().ToString("N"), action = action }; foreach (var (name, value) in options) if (Fields.TryGetValue(name, out var assign)) assign(command, value); await cache.AddAsync(command, new CultRecordHandle(BrokkrCommandLedger.CommandKey(command.commandId))); await cache.FlushAsync(); var waitMilliseconds = options.TryGetValue("--wait-ms", out var wait) ? Integer("--wait-ms", wait) : 10000; if (waitMilliseconds <= 0) { Console.WriteLine(command.commandId); return 0; } var receiptKey = BrokkrCommandLedger.ReceiptKey(command.commandId); var deadline = DateTimeOffset.UtcNow.AddMilliseconds(waitMilliseconds); while (DateTimeOffset.UtcNow < deadline) { await Task.Delay(100); await cache.PullAllBackingStoresAsync(); if (!cache.TryGet(receiptKey, out BrokkrUnityCommandReceipt? receipt) || receipt == null) continue; Console.WriteLine($"{receipt.status}: {receipt.message}"); if (!string.IsNullOrWhiteSpace(receipt.objectId)) Console.WriteLine(receipt.objectId); // The exit code is the receipt's verdict, never the write's success. return string.Equals(receipt.status, "accepted", StringComparison.Ordinal) ? 0 : 1; } var host = ReadHostSnapshot(cache); var why = host is { agentCommandsEnabled: false } ? " The editor reports agent commands off; the operator enables them in the Brokkr window." : ""; throw new TimeoutException( $"Timed out waiting for Brokkr receipt '{receiptKey.Value}'; the intent stays written.{why}"); } private static BrokkrHostSnapshot? ReadHostSnapshot(CultCache cache) => cache.TryGet(new CultRecordKey("unity/host/current"), out BrokkrHostSnapshot? host) ? host : null; private static Dictionary Parse(string[] args) { var values = new Dictionary(StringComparer.Ordinal); for (var index = 0; index < args.Length; index += 2) { if (index + 1 >= args.Length || !args[index].StartsWith("--", StringComparison.Ordinal)) throw new ArgumentException("Arguments must be --name value pairs."); var name = args[index]; if (name is not ("--unity-cache" or "--action" or "--command-id" or "--wait-ms") && !Fields.ContainsKey(name)) throw new ArgumentException($"Unknown option {name}."); values[name] = args[index + 1]; } return values; } private static string Required(Dictionary options, string name) => options.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value) ? value : throw new ArgumentException($"{name} is required."); private static int Integer(string name, string value) => int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) ? parsed : throw new ArgumentException($"{name} must be an integer."); }