using System.CommandLine; using System.CommandLine.Parsing; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.CLI.Commands; public static class TemplateCommands { /// Builds the template command with its subcommands using the given shared CLI options. /// Shared management URL option. /// Shared output format option. /// Shared username option for authentication. /// Shared password option for authentication. /// The fully configured template command with all its subcommands. public static Command Build(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var command = new Command("template") { Description = "Manage templates" }; command.Add(BuildList(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildGet(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildCreate(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildUpdate(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildValidate(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildDelete(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildAttribute(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildAlarm(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildNativeAlarmSource(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildScript(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildComposition(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildResyncMembers(urlOption, formatOption, usernameOption, passwordOption)); return command; } private static Command BuildResyncMembers(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "Template ID (its derived subtree is included)", Required = true }; var cmd = new Command("resync-members") { Description = "Resync inherited members onto a template and its derived subtree " + "(materialize missing, re-sync drifted, remove orphaned)" }; cmd.Add(idOption); cmd.SetAction(async (ParseResult result) => { var id = result.GetValue(idOption); return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, new ResyncInheritedMembersCommand(id)); }); return cmd; } private static Command BuildList(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var detailOption = new Option("--detail") { Description = "Include full template definitions (all attributes/alarms/scripts) in table output. " + "Without it, table output is a compact summary (counts only). JSON output is always full." }; var cmd = new Command("list") { Description = "List all templates (compact table summary; use --detail for the full dump)" }; cmd.Add(detailOption); cmd.SetAction(async (ParseResult result) => { var detail = result.GetValue(detailOption); return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, new ListTemplatesCommand(), tableProjector: detail ? null : TemplateTableProjection.ProjectSummary); }); return cmd; } private static Command BuildGet(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "Template ID", Required = true }; var detailOption = new Option("--detail") { Description = "Include full template definitions (all attributes/alarms/scripts) in table output. " + "Without it, table output is a compact summary (counts only). JSON output is always full." }; var cmd = new Command("get") { Description = "Get a template by ID (compact table summary; use --detail for the full dump)" }; cmd.Add(idOption); cmd.Add(detailOption); cmd.SetAction(async (ParseResult result) => { var id = result.GetValue(idOption); var detail = result.GetValue(detailOption); return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, new GetTemplateCommand(id), tableProjector: detail ? null : TemplateTableProjection.ProjectSummary); }); return cmd; } private static Command BuildCreate(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var nameOption = new Option("--name") { Description = "Template name", Required = true }; var descOption = new Option("--description") { Description = "Template description" }; var parentOption = new Option("--parent-id") { Description = "Parent template ID" }; var cmd = new Command("create") { Description = "Create a new template" }; cmd.Add(nameOption); cmd.Add(descOption); cmd.Add(parentOption); cmd.SetAction(async (ParseResult result) => { var name = result.GetValue(nameOption)!; var desc = result.GetValue(descOption); var parentId = result.GetValue(parentOption); return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, new CreateTemplateCommand(name, desc, parentId)); }); return cmd; } private static Command BuildUpdate(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "Template ID", Required = true }; var nameOption = new Option("--name") { Description = "Template name", Required = true }; var descOption = new Option("--description") { Description = "Template description. Omit to leave unchanged; pass an empty string (\"\") to clear it." }; var parentOption = new Option("--parent-id") { Description = "Parent template ID. Immutable after creation; omit to leave unchanged." }; var cmd = new Command("update") { Description = "Update a template (omitted optional fields are left unchanged)" }; cmd.Add(idOption); cmd.Add(nameOption); cmd.Add(descOption); cmd.Add(parentOption); cmd.SetAction(async (ParseResult result) => { var id = result.GetValue(idOption); var name = result.GetValue(nameOption)!; var desc = result.GetValue(descOption); var parentId = result.GetValue(parentOption); return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, new UpdateTemplateCommand(id, name, desc, parentId)); }); return cmd; } private static Command BuildValidate(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "Template ID", Required = true }; var cmd = new Command("validate") { Description = "Validate a template" }; cmd.Add(idOption); cmd.SetAction(async (ParseResult result) => { var id = result.GetValue(idOption); return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, new ValidateTemplateCommand(id)); }); return cmd; } private static Command BuildDelete(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var idOption = new Option("--id") { Description = "Template ID", Required = true }; var cmd = new Command("delete") { Description = "Delete a template" }; cmd.Add(idOption); cmd.SetAction(async (ParseResult result) => { var id = result.GetValue(idOption); return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, new DeleteTemplateCommand(id)); }); return cmd; } private static Command BuildAttribute(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var group = new Command("attribute") { Description = "Manage template attributes" }; var templateIdOption = new Option("--template-id") { Description = "Template ID", Required = true }; var nameOption = new Option("--name") { Description = "Attribute name", Required = true }; var dataTypeOption = new Option("--data-type") { Description = "Data type", Required = true }; var valueOption = new Option("--value") { Description = "Default value. For a List attribute, supply a JSON array in native form: numeric/boolean elements unquoted (e.g. an Int32 list '[10,20,30]'), string elements quoted (e.g. '[\"WO-1\",\"WO-2\"]')." }; var descOption = new Option("--description") { Description = "Description" }; var sourceOption = new Option("--data-source") { Description = "Data source reference" }; var elementTypeOption = new Option("--element-type") { Description = ElementTypeOptionDescription }; var lockedOption = new Option("--locked") { Description = "Lock status" }; lockedOption.DefaultValueFactory = _ => false; var addCmd = new Command("add") { Description = "Add an attribute to a template" }; addCmd.Add(templateIdOption); addCmd.Add(nameOption); addCmd.Add(dataTypeOption); addCmd.Add(valueOption); addCmd.Add(descOption); addCmd.Add(sourceOption); addCmd.Add(elementTypeOption); addCmd.Add(lockedOption); addCmd.SetAction(async (ParseResult result) => { var dataType = result.GetValue(dataTypeOption)!; var elementType = result.GetValue(elementTypeOption); if (!TryValidateElementType(dataType, elementType, out var error)) { OutputFormatter.WriteError(error!, "INVALID_ARGUMENT"); return 1; } return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, BuildAddAttributeCommand( result.GetValue(templateIdOption), result.GetValue(nameOption)!, dataType, result.GetValue(valueOption), result.GetValue(descOption), result.GetValue(sourceOption), result.GetValue(lockedOption), elementType)); }); group.Add(addCmd); var updateIdOption = new Option("--id") { Description = "Attribute ID", Required = true }; var updateNameOption = new Option("--name") { Description = "Attribute name", Required = true }; var updateDataTypeOption = new Option("--data-type") { Description = "Data type", Required = true }; var updateValueOption = new Option("--value") { Description = "Default value. For a List attribute, supply a JSON array in native form: numeric/boolean elements unquoted (e.g. an Int32 list '[10,20,30]'), string elements quoted (e.g. '[\"WO-1\",\"WO-2\"]')." }; var updateDescOption = new Option("--description") { Description = "Description" }; var updateSourceOption = new Option("--data-source") { Description = "Data source reference" }; var updateElementTypeOption = new Option("--element-type") { Description = ElementTypeOptionDescription }; var updateLockedOption = new Option("--locked") { Description = "Lock status" }; updateLockedOption.DefaultValueFactory = _ => false; var updateCmd = new Command("update") { Description = "Update a template attribute" }; updateCmd.Add(updateIdOption); updateCmd.Add(updateNameOption); updateCmd.Add(updateDataTypeOption); updateCmd.Add(updateValueOption); updateCmd.Add(updateDescOption); updateCmd.Add(updateSourceOption); updateCmd.Add(updateElementTypeOption); updateCmd.Add(updateLockedOption); updateCmd.SetAction(async (ParseResult result) => { var dataType = result.GetValue(updateDataTypeOption)!; var elementType = result.GetValue(updateElementTypeOption); if (!TryValidateElementType(dataType, elementType, out var error)) { OutputFormatter.WriteError(error!, "INVALID_ARGUMENT"); return 1; } return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, BuildUpdateAttributeCommand( result.GetValue(updateIdOption), result.GetValue(updateNameOption)!, dataType, result.GetValue(updateValueOption), result.GetValue(updateDescOption), result.GetValue(updateSourceOption), result.GetValue(updateLockedOption), elementType)); }); group.Add(updateCmd); var deleteIdOption = new Option("--id") { Description = "Attribute ID", Required = true }; var deleteCmd = new Command("delete") { Description = "Delete a template attribute" }; deleteCmd.Add(deleteIdOption); deleteCmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, new DeleteTemplateAttributeCommand(result.GetValue(deleteIdOption))); }); group.Add(deleteCmd); return group; } /// Shared description for the --element-type option on attribute add/update. internal const string ElementTypeOptionDescription = "Element scalar type for a List attribute (String, Int32, Float, Double, Boolean, DateTime). Required when --data-type is List."; /// Shared description for the --min-time-between-runs option on script add/update. internal const string MinTimeBetweenRunsOptionDescription = "Minimum time between script runs (a throttle for triggered scripts; the re-fire interval for a WhileTrue trigger). " + "Accepts a value with a unit suffix: ms, s/sec, or m/min (e.g. 500ms, 5s, 2min); a bare number is seconds. " + "Omit, or pass 0 / a blank value, to leave it unset (no throttle)."; /// Shared description for the --execution-timeout-seconds option on script add/update. internal const string ExecutionTimeoutOptionDescription = "Per-script execution timeout in seconds. Omit (or pass a non-positive value) to use the site's global " + "default (SiteRuntimeOptions.ScriptExecutionTimeoutSeconds)."; /// Shared description for the script --trigger-kind option on script add/update. internal const string ScriptTriggerKindOptionDescription = "Expression trigger analysis kind: advisory (default) or strict. " + "Strict escalates a blank expression from a warning to a deploy-blocking error. " + "Requires --trigger-config: with no config the kind is ignored (a warning is printed to stderr)."; /// Shared description for the alarm --trigger-kind option on alarm update. internal const string AlarmTriggerKindOptionDescription = "Expression trigger analysis kind: advisory (default) or strict. " + "Requires --trigger-config: with no config the kind is ignored (a warning is printed to stderr)."; /// /// The element scalar types permitted for a List attribute — derived from the /// single source of truth, , /// so the CLI never drifts from the codec/Management API. /// private static readonly string[] ValidElementScalars = Enum.GetValues() .Where(AttributeValueCodec.IsValidElementType) .Select(t => t.ToString()) .ToArray(); /// /// Validates the --data-type / --element-type combination client-side so /// the CLI fails fast with a clear message before contacting the Management API (the /// server validates independently). A List attribute requires a valid element scalar; /// a non-List attribute must not carry an element type. Comparison is case-insensitive. /// /// The raw --data-type value. /// The raw --element-type value, or null if absent. /// A descriptive error message when validation fails; otherwise null. /// true when the combination is valid; otherwise false. internal static bool TryValidateElementType(string dataType, string? elementType, out string? error) { error = null; var isList = string.Equals(dataType, "List", StringComparison.OrdinalIgnoreCase); var hasElementType = !string.IsNullOrWhiteSpace(elementType); if (isList) { if (!hasElementType) { error = "--element-type is required when --data-type is List " + "(one of: String, Int32, Float, Double, Boolean, DateTime)."; return false; } if (!Enum.TryParse(elementType!.Trim(), ignoreCase: true, out var parsed) || !AttributeValueCodec.IsValidElementType(parsed)) { error = $"Invalid --element-type '{elementType}'. Valid List element scalars are: " + string.Join(", ", ValidElementScalars) + "."; return false; } return true; } if (hasElementType) { error = "--element-type is only valid when --data-type is List."; return false; } return true; } /// /// Builds the payload sent to the Management API. /// The raw string is forwarded unchanged — for a List attribute it /// is a JSON array, which the API/codec parses; the CLI does not reshape it. /// internal static AddTemplateAttributeCommand BuildAddAttributeCommand( int templateId, string name, string dataType, string? value, string? description, string? dataSource, bool isLocked, string? elementType) => new(templateId, name, dataType, value, description, dataSource, isLocked, NormalizeElementType(elementType)); /// /// Builds the payload sent to the Management API. /// The raw string is forwarded unchanged — for a List attribute it /// is a JSON array, which the API/codec parses; the CLI does not reshape it. /// internal static UpdateTemplateAttributeCommand BuildUpdateAttributeCommand( int attributeId, string name, string dataType, string? value, string? description, string? dataSource, bool isLocked, string? elementType) => new(attributeId, name, dataType, value, description, dataSource, isLocked, NormalizeElementType(elementType)); /// Trims a non-empty element type; an empty/whitespace value becomes null (no element type). private static string? NormalizeElementType(string? elementType) => string.IsNullOrWhiteSpace(elementType) ? null : elementType.Trim(); /// /// Parses the raw --min-time-between-runs value into a . /// A blank/absent value means "unset" ( null → null, no error). /// Otherwise the value must be a positive integer with an optional unit suffix: /// ms, s/sec, or m/min; a bare number is interpreted as /// seconds (matching the UI duration input's default unit). A value of 0 resolves to /// null (unset), mirroring DurationInput.Compose. The unit set and "bare = seconds" /// default deliberately mirror the Central UI duration input so the two authoring surfaces agree. /// /// The raw flag value, or null when the flag was omitted. /// The parsed duration, or null for an absent/unset value. /// A descriptive error message when parsing fails; otherwise null. /// true when the value is absent or parses cleanly; false on a malformed value. internal static bool TryParseMinTimeBetweenRuns(string? value, out TimeSpan? duration, out string? error) { duration = null; error = null; var trimmed = value?.Trim(); if (string.IsNullOrEmpty(trimmed)) return true; // omitted → unset // Split a trailing alphabetic unit suffix from the leading numeric part. var splitIndex = trimmed.Length; while (splitIndex > 0 && char.IsLetter(trimmed[splitIndex - 1])) splitIndex--; var numberPart = trimmed[..splitIndex]; var unitPart = trimmed[splitIndex..].ToLowerInvariant(); if (!long.TryParse(numberPart, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var amount) || amount < 0) { error = $"Invalid --min-time-between-runs '{value}'. Expected a non-negative number with an optional " + "unit suffix (ms, s/sec, m/min); a bare number is seconds (e.g. 500ms, 5s, 2min)."; return false; } var factorMs = unitPart switch { "ms" => 1L, "" or "s" or "sec" => 1000L, "m" or "min" => 60000L, _ => -1L, }; if (factorMs < 0) { error = $"Invalid --min-time-between-runs unit in '{value}'. Valid units are: ms, s/sec, m/min."; return false; } // Guard against overflow: reject if amount * factorMs would exceed TimeSpan.MaxValue (in ms). // The divide is safe because factorMs is always >= 1. if (amount > TimeSpan.MaxValue.TotalMilliseconds / factorMs) { error = $"Invalid --min-time-between-runs '{value}': value is too large."; return false; } // 0 (with or without a unit) → unset, mirroring DurationInput.Compose's non-positive handling. duration = amount == 0 ? null : TimeSpan.FromMilliseconds(amount * factorMs); return true; } /// /// Determines whether --trigger-kind was supplied without a --trigger-config. /// In that case has no JSON object to inject /// into and silently drops the kind (#257) — the caller should warn the user. /// /// The raw --trigger-config JSON value, or null when absent. /// The raw --trigger-kind value, or null when absent. /// true when a kind was given but no config — i.e. the kind will be ignored. internal static bool TriggerKindWillBeIgnored(string? triggerConfig, string? triggerKind) => !string.IsNullOrWhiteSpace(triggerKind) && string.IsNullOrWhiteSpace(triggerConfig); /// /// Warns (to stderr) when --trigger-kind is supplied without a --trigger-config /// (see ). The command still proceeds — the kind is /// advisory metadata, not a required field — but the user is told it had no effect so they are /// not surprised that a strict request did not take. Warn-and-continue (rather than a hard /// error) keeps the flag combination non-fatal: a script/alarm is still created, just without the /// requested analysis kind. /// /// The raw --trigger-config JSON value, or null when absent. /// The raw --trigger-kind value, or null when absent. private static void WarnIfTriggerKindIgnored(string? triggerConfig, string? triggerKind) { if (TriggerKindWillBeIgnored(triggerConfig, triggerKind)) { Console.Error.WriteLine( $"warning: --trigger-kind '{triggerKind!.Trim()}' was ignored because no --trigger-config was supplied. " + "The analysis kind is written into the trigger-config JSON, so it has no effect without one."); } } private static Command BuildAlarm(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var group = new Command("alarm") { Description = "Manage template alarms" }; var templateIdOption = new Option("--template-id") { Description = "Template ID", Required = true }; var nameOption = new Option("--name") { Description = "Alarm name", Required = true }; var triggerTypeOption = new Option("--trigger-type") { Description = "Trigger type", Required = true }; var priorityOption = new Option("--priority") { Description = "Alarm priority", Required = true }; var descOption = new Option("--description") { Description = "Description" }; var triggerConfigOption = new Option("--trigger-config") { Description = "Trigger configuration JSON" }; var lockedOption = new Option("--locked") { Description = "Lock status" }; lockedOption.DefaultValueFactory = _ => false; // Typed setpoint flags (alternative to raw --trigger-config; raw wins when both supplied). var attributeOption = new Option("--attribute") { Description = "Attribute name the trigger watches (all trigger types except Expression)" }; var matchValueOption = new Option("--match-value") { Description = "ValueMatch: value to compare against" }; var notEqualsOption = new Option("--not-equals") { Description = "ValueMatch: match when the value is NOT equal (emits !=)" }; notEqualsOption.DefaultValueFactory = _ => false; var minOption = new Option("--min") { Description = "RangeViolation: minimum allowed value" }; var maxOption = new Option("--max") { Description = "RangeViolation: maximum allowed value" }; var thresholdOption = new Option("--threshold-per-second") { Description = "RateOfChange: rate threshold per second" }; var windowOption = new Option("--window-seconds") { Description = "RateOfChange: sliding window in seconds" }; var directionOption = new Option("--direction") { Description = "RateOfChange: direction (rising|falling|either)" }; var loLoOption = new Option("--lolo") { Description = "HiLo: low-low setpoint" }; var loOption = new Option("--lo") { Description = "HiLo: low setpoint" }; var hiOption = new Option("--hi") { Description = "HiLo: high setpoint" }; var hiHiOption = new Option("--hihi") { Description = "HiLo: high-high setpoint" }; var expressionOption = new Option("--expression") { Description = "Expression: boolean trigger expression" }; // M9-T28b: analysis kind for Expression triggers (advisory|strict; default advisory). // Writes "analysisKind":"Strict" into the trigger config when set to strict. var triggerKindOption = new Option("--trigger-kind") { Description = "Expression trigger analysis kind: advisory (default) or strict. " + "Strict escalates a blank expression from a warning to a deploy-blocking error." }; var addCmd = new Command("add") { Description = "Add an alarm to a template" }; addCmd.Add(templateIdOption); addCmd.Add(nameOption); addCmd.Add(triggerTypeOption); addCmd.Add(priorityOption); addCmd.Add(descOption); addCmd.Add(triggerConfigOption); addCmd.Add(lockedOption); addCmd.Add(attributeOption); addCmd.Add(matchValueOption); addCmd.Add(notEqualsOption); addCmd.Add(minOption); addCmd.Add(maxOption); addCmd.Add(thresholdOption); addCmd.Add(windowOption); addCmd.Add(directionOption); addCmd.Add(loLoOption); addCmd.Add(loOption); addCmd.Add(hiOption); addCmd.Add(hiHiOption); addCmd.Add(expressionOption); addCmd.Add(triggerKindOption); addCmd.SetAction(async (ParseResult result) => { var triggerType = result.GetValue(triggerTypeOption)!; var rawConfig = result.GetValue(triggerConfigOption); var triggerConfig = rawConfig ?? AlarmTriggerConfigJson.Build( triggerType, result.GetValue(attributeOption), result.GetValue(matchValueOption), result.GetValue(notEqualsOption), result.GetValue(minOption), result.GetValue(maxOption), result.GetValue(thresholdOption), result.GetValue(windowOption), result.GetValue(directionOption), result.GetValue(loLoOption), result.GetValue(loOption), result.GetValue(hiOption), result.GetValue(hiHiOption), result.GetValue(expressionOption), result.GetValue(triggerKindOption)); return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, new AddTemplateAlarmCommand( result.GetValue(templateIdOption), result.GetValue(nameOption)!, triggerType, result.GetValue(priorityOption)!, result.GetValue(descOption), triggerConfig, result.GetValue(lockedOption))); }); group.Add(addCmd); var updateIdOption = new Option("--id") { Description = "Alarm ID", Required = true }; var updateNameOption = new Option("--name") { Description = "Alarm name", Required = true }; var updateTriggerTypeOption = new Option("--trigger-type") { Description = "Trigger type", Required = true }; var updatePriorityOption = new Option("--priority") { Description = "Alarm priority", Required = true }; var updateDescOption = new Option("--description") { Description = "Description" }; var updateTriggerConfigOption = new Option("--trigger-config") { Description = "Trigger configuration JSON" }; var updateLockedOption = new Option("--locked") { Description = "Lock status" }; updateLockedOption.DefaultValueFactory = _ => false; // M9-T28b: --trigger-kind for update (same semantics as add) var updateTriggerKindOption = new Option("--trigger-kind") { Description = AlarmTriggerKindOptionDescription }; var updateCmd = new Command("update") { Description = "Update a template alarm" }; updateCmd.Add(updateIdOption); updateCmd.Add(updateNameOption); updateCmd.Add(updateTriggerTypeOption); updateCmd.Add(updatePriorityOption); updateCmd.Add(updateDescOption); updateCmd.Add(updateTriggerConfigOption); updateCmd.Add(updateLockedOption); updateCmd.Add(updateTriggerKindOption); updateCmd.SetAction(async (ParseResult result) => { var rawConfig = result.GetValue(updateTriggerConfigOption); WarnIfTriggerKindIgnored(rawConfig, result.GetValue(updateTriggerKindOption)); var triggerConfig = TriggerConfigJson.InjectAnalysisKind( rawConfig, result.GetValue(updateTriggerKindOption)); return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, new UpdateTemplateAlarmCommand( result.GetValue(updateIdOption), result.GetValue(updateNameOption)!, result.GetValue(updateTriggerTypeOption)!, result.GetValue(updatePriorityOption)!, result.GetValue(updateDescOption), triggerConfig, result.GetValue(updateLockedOption))); }); group.Add(updateCmd); var deleteIdOption = new Option("--id") { Description = "Alarm ID", Required = true }; var deleteCmd = new Command("delete") { Description = "Delete a template alarm" }; deleteCmd.Add(deleteIdOption); deleteCmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, new DeleteTemplateAlarmCommand(result.GetValue(deleteIdOption))); }); group.Add(deleteCmd); return group; } private static Command BuildNativeAlarmSource(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var group = new Command("native-alarm-source") { Description = "Manage template native alarm source bindings (read-only mirror of OPC UA A&C / MxGateway alarms)" }; // add var templateIdOption = new Option("--template-id") { Description = "Template ID", Required = true }; var nameOption = new Option("--name") { Description = "Source binding name", Required = true }; var connectionOption = new Option("--connection") { Description = "Alarm-capable data connection name", Required = true }; var sourceRefOption = new Option("--source-ref") { Description = "Source reference (OPC UA SourceNode nodeId, or MxAccess object/area)", Required = true }; var filterOption = new Option("--filter") { Description = "Optional condition filter (null = mirror all conditions under the source)" }; var descOption = new Option("--description") { Description = "Description" }; var lockedOption = new Option("--locked") { Description = "Lock status" }; lockedOption.DefaultValueFactory = _ => false; var addCmd = new Command("add") { Description = "Add a native alarm source binding to a template" }; addCmd.Add(templateIdOption); addCmd.Add(nameOption); addCmd.Add(connectionOption); addCmd.Add(sourceRefOption); addCmd.Add(filterOption); addCmd.Add(descOption); addCmd.Add(lockedOption); addCmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, new AddTemplateNativeAlarmSourceCommand( result.GetValue(templateIdOption), result.GetValue(nameOption)!, result.GetValue(connectionOption)!, result.GetValue(sourceRefOption)!, result.GetValue(filterOption), result.GetValue(descOption), result.GetValue(lockedOption))); }); group.Add(addCmd); // list var listIdOption = new Option("--template-id") { Description = "Template ID", Required = true }; var listCmd = new Command("list") { Description = "List native alarm source bindings on a template" }; listCmd.Add(listIdOption); listCmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, new ListTemplateNativeAlarmSourcesCommand(result.GetValue(listIdOption))); }); group.Add(listCmd); // remove var removeIdOption = new Option("--id") { Description = "Native alarm source ID", Required = true }; var removeCmd = new Command("remove") { Description = "Remove a native alarm source binding from a template" }; removeCmd.Add(removeIdOption); removeCmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, new DeleteTemplateNativeAlarmSourceCommand(result.GetValue(removeIdOption))); }); group.Add(removeCmd); return group; } private static Command BuildScript(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var group = new Command("script") { Description = "Manage template scripts" }; var templateIdOption = new Option("--template-id") { Description = "Template ID", Required = true }; var nameOption = new Option("--name") { Description = "Script name", Required = true }; var codeOption = new Option("--code") { Description = "Script code", Required = true }; var triggerTypeOption = new Option("--trigger-type") { Description = "Trigger type", Required = true }; var triggerConfigOption = new Option("--trigger-config") { Description = "Trigger configuration JSON" }; var lockedOption = new Option("--locked") { Description = "Lock status" }; lockedOption.DefaultValueFactory = _ => false; var paramsOption = new Option("--parameters") { Description = "Parameter definitions JSON" }; var returnOption = new Option("--return-def") { Description = "Return definition JSON" }; var minTimeOption = new Option("--min-time-between-runs") { Description = MinTimeBetweenRunsOptionDescription }; var execTimeoutOption = new Option("--execution-timeout-seconds") { Description = ExecutionTimeoutOptionDescription }; // M9-T28b: analysis kind for Expression triggers (advisory|strict; default advisory). var scriptTriggerKindOption = new Option("--trigger-kind") { Description = ScriptTriggerKindOptionDescription }; var addCmd = new Command("add") { Description = "Add a script to a template" }; addCmd.Add(templateIdOption); addCmd.Add(nameOption); addCmd.Add(codeOption); addCmd.Add(triggerTypeOption); addCmd.Add(triggerConfigOption); addCmd.Add(lockedOption); addCmd.Add(paramsOption); addCmd.Add(returnOption); addCmd.Add(minTimeOption); addCmd.Add(execTimeoutOption); addCmd.Add(scriptTriggerKindOption); addCmd.SetAction(async (ParseResult result) => { if (!TryParseMinTimeBetweenRuns(result.GetValue(minTimeOption), out var minTime, out var minTimeError)) { OutputFormatter.WriteError(minTimeError!, "INVALID_ARGUMENT"); return 1; } var rawConfig = result.GetValue(triggerConfigOption); WarnIfTriggerKindIgnored(rawConfig, result.GetValue(scriptTriggerKindOption)); var triggerConfig = TriggerConfigJson.InjectAnalysisKind( rawConfig, result.GetValue(scriptTriggerKindOption)); return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, new AddTemplateScriptCommand( result.GetValue(templateIdOption), result.GetValue(nameOption)!, result.GetValue(codeOption)!, result.GetValue(triggerTypeOption)!, triggerConfig, result.GetValue(lockedOption), result.GetValue(paramsOption), result.GetValue(returnOption), minTime, result.GetValue(execTimeoutOption))); }); group.Add(addCmd); var updateIdOption = new Option("--id") { Description = "Script ID", Required = true }; var updateNameOption = new Option("--name") { Description = "Script name", Required = true }; var updateCodeOption = new Option("--code") { Description = "Script code", Required = true }; var updateTriggerTypeOption = new Option("--trigger-type") { Description = "Trigger type", Required = true }; var updateTriggerConfigOption = new Option("--trigger-config") { Description = "Trigger configuration JSON" }; var updateLockedOption = new Option("--locked") { Description = "Lock status" }; updateLockedOption.DefaultValueFactory = _ => false; var updateParamsOption = new Option("--parameters") { Description = "Parameter definitions JSON" }; var updateReturnOption = new Option("--return-def") { Description = "Return definition JSON" }; var updateMinTimeOption = new Option("--min-time-between-runs") { Description = MinTimeBetweenRunsOptionDescription }; var updateExecTimeoutOption = new Option("--execution-timeout-seconds") { Description = ExecutionTimeoutOptionDescription }; // M9-T28b: --trigger-kind for update (same semantics as add) var updateScriptTriggerKindOption = new Option("--trigger-kind") { Description = ScriptTriggerKindOptionDescription }; var updateCmd = new Command("update") { Description = "Update a template script" }; updateCmd.Add(updateIdOption); updateCmd.Add(updateNameOption); updateCmd.Add(updateCodeOption); updateCmd.Add(updateTriggerTypeOption); updateCmd.Add(updateTriggerConfigOption); updateCmd.Add(updateLockedOption); updateCmd.Add(updateParamsOption); updateCmd.Add(updateReturnOption); updateCmd.Add(updateMinTimeOption); updateCmd.Add(updateExecTimeoutOption); updateCmd.Add(updateScriptTriggerKindOption); updateCmd.SetAction(async (ParseResult result) => { if (!TryParseMinTimeBetweenRuns(result.GetValue(updateMinTimeOption), out var minTime, out var minTimeError)) { OutputFormatter.WriteError(minTimeError!, "INVALID_ARGUMENT"); return 1; } var rawConfig = result.GetValue(updateTriggerConfigOption); WarnIfTriggerKindIgnored(rawConfig, result.GetValue(updateScriptTriggerKindOption)); var triggerConfig = TriggerConfigJson.InjectAnalysisKind( rawConfig, result.GetValue(updateScriptTriggerKindOption)); return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, new UpdateTemplateScriptCommand( result.GetValue(updateIdOption), result.GetValue(updateNameOption)!, result.GetValue(updateCodeOption)!, result.GetValue(updateTriggerTypeOption)!, triggerConfig, result.GetValue(updateLockedOption), result.GetValue(updateParamsOption), result.GetValue(updateReturnOption), minTime, result.GetValue(updateExecTimeoutOption))); }); group.Add(updateCmd); var deleteIdOption = new Option("--id") { Description = "Script ID", Required = true }; var deleteCmd = new Command("delete") { Description = "Delete a template script" }; deleteCmd.Add(deleteIdOption); deleteCmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, new DeleteTemplateScriptCommand(result.GetValue(deleteIdOption))); }); group.Add(deleteCmd); return group; } private static Command BuildComposition(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var group = new Command("composition") { Description = "Manage template compositions" }; var templateIdOption = new Option("--template-id") { Description = "Template ID", Required = true }; var instanceNameOption = new Option("--instance-name") { Description = "Composed instance name", Required = true }; var composedTemplateIdOption = new Option("--composed-template-id") { Description = "Composed template ID", Required = true }; var addCmd = new Command("add") { Description = "Add a composition to a template" }; addCmd.Add(templateIdOption); addCmd.Add(instanceNameOption); addCmd.Add(composedTemplateIdOption); addCmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, new AddTemplateCompositionCommand( result.GetValue(templateIdOption), result.GetValue(instanceNameOption)!, result.GetValue(composedTemplateIdOption))); }); group.Add(addCmd); var deleteIdOption = new Option("--id") { Description = "Composition ID", Required = true }; var deleteCmd = new Command("delete") { Description = "Delete a template composition" }; deleteCmd.Add(deleteIdOption); deleteCmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, new DeleteTemplateCompositionCommand(result.GetValue(deleteIdOption))); }); group.Add(deleteCmd); return group; } }