--- name: pacx-command-writer description: > Guide for writing new PACX CLI commands (verbs) following established project conventions. Use this skill when asked to create, scaffold, or add a new command to the Greg.Xrm.Command repository. --- # PACX Command Writer Every PACX command is a pair of files in `Greg.Xrm.Command.Core\Commands\\`. Executors need no manual DI registration: `Extensions.RegisterCommandExecutors()` auto-scans all `ICommandExecutor` implementations at startup. Supporting services and repositories must be registered explicitly against their interfaces in `IoCModule`. ## Rules - Plan in advance β€” think through the command's options, validation, and usage examples before implementation. - Draft an implementation plan in markdown in the /docs folder before coding. - The implementation plan must contain the list of steps required to implement the command. - When you start working on a single step, update the step title putting a πŸ•’ before the step title - When a step is completed, update the step title putting a βœ… before the step title - Implementation plan must include also unit tests for all implemented steps. - Commit after each implementation step. - Before pushing, run a code review of the changes to ensure adherence to project conventions and correctness. - If in the code you need to use dataverse tables, create a model class in /Greg.Xrm.Command.Core/Model implementing `EntityWrapper` and a repository to manage the queries. Use `Greg.Xrm.Command.Core\Model\WebResource.cs` as a reference sample. --- ## File 1 β€” `Command.cs` (options holder) This class declares CLI options via attributes and optionally handles cross-option validation and usage examples. It must **not** contain business logic. ```csharp using Greg.Xrm.Command.Parsing; // ICanProvideUsageExample, MarkdownWriter using Greg.Xrm.Command.Services; // MarkdownWriter (via Parsing namespace) using System.ComponentModel.DataAnnotations; // [Required], IValidatableObject namespace Greg.Xrm.Command.Commands. { [Command("", "", HelpText = "One-sentence description.")] [Alias("", "")] // only add when reversed order is natural public class Command : IValidatableObject, ICanProvideUsageExample // ───────────────────────────────── ────────────────────── // implement only when cross-option implement for non-trivial // constraints exist commands to aid discoverability { // Required options first β€” low Order values (1, 2, 3…) [Option("name", "n", Order = 1, HelpText = "...")] [Required] public string? Name { get; set; } // Optional options β€” grouped by concern, higher Order values [Option("description", "d", Order = 10, HelpText = "...")] public string? Description { get; set; } // Solution is always last (Order = 50+) [Option("solution", "s", Order = 50, HelpText = "Unmanaged solution name. Uses the current default solution if omitted.")] public string? SolutionName { get; set; } // Cross-option validation only β€” single-option guards go in the executor public IEnumerable Validate(ValidationContext validationContext) { if (/* mutually exclusive condition */) yield return new ValidationResult("...", new[] { nameof(OptionA), nameof(OptionB) }); } public void WriteUsageExamples(MarkdownWriter writer) { writer.WriteParagraph("Minimal usage:"); writer.WriteCodeBlock("pacx --name \"value\"", "Powershell"); } } } ``` ### `[Option]` conventions | Property | Rule | | -------------- | --------------------------------------------------------------------------- | | `longName` | **camelCase** β€” e.g. `schemaName`, `displayName`, `requiredLevel` | | `shortName` | Short abbreviation, unique within the command β€” e.g. `"n"`, `"sn"`, `"par"` | | `HelpText` | Always set β€” shown in `pacx help` and interactive mode | | `Order` | Required options: 1–9; grouped optional options: 10–49; `--solution`: 50+ | | `DefaultValue` | Set on the attribute when there is a meaningful default the user should see | | `[Required]` | Mark all mandatory options | ### `[Command]` and `[Alias]` conventions - Verb order in `[Command]` is always **noun first**: `[Command("table", "create", ...)]`. - `[Alias]` reverses the order when natural: `[Alias("create", "table")]`. - Only add `[Alias]` when the reversed form is genuinely useful. --- ## File 2 β€” `CommandExecutor.cs` (business logic) ```csharp using Greg.Xrm.Command.Services.Connection; using Greg.Xrm.Command.Services.Output; using Microsoft.Xrm.Sdk; using Microsoft.Xrm.Sdk.Messages; using System.ServiceModel; // FaultException namespace Greg.Xrm.Command.Commands. { public class CommandExecutor( IOutput output, IOrganizationServiceRepository organizationServiceRepository) : ICommandExecutor<Command> { public async Task ExecuteAsync(Command command, CancellationToken cancellationToken) { // Step 1 β€” always connect first output.Write("Connecting to the current dataverse environment..."); var crm = await organizationServiceRepository.GetCurrentConnectionAsync(); output.WriteLine("Done", ConsoleColor.Green); try { // Step 2 β€” resolve default solution when the option is omitted var solutionName = command.SolutionName; if (string.IsNullOrWhiteSpace(solutionName)) { solutionName = await organizationServiceRepository.GetCurrentDefaultSolutionAsync(); if (solutionName == null) return CommandResult.Fail("No solution name provided and no current solution name found in the settings."); } // Step 3 β€” report each logical step inline output.Write("Performing the operation..."); var request = new OrganizationRequest(/* ... */); var response = await crm.ExecuteAsync(request); output.WriteLine(" Done", ConsoleColor.Green); // Step 4 β€” return success with key output values var result = CommandResult.Success(); result["EntityId"] = response./* ... */; return result; } catch (FaultException ex) { return CommandResult.Fail(ex.Message, ex); } } // Non-trivial computations go in private static helpers to keep ExecuteAsync readable private static string ComputeSchemaName(string displayName, string publisherPrefix) { // ... } } } ``` ### Executor conventions - **MUST: keep `Greg.Xrm.Command.Commands` namespaces limited to commands, command executors, and command-specific helpers.** Put supporting service implementations and their interfaces under `Greg.Xrm.Command.Services` (with a domain subnamespace when useful), and models/repositories and their interfaces under `Greg.Xrm.Command.Model`. Never place services or their contracts in a command folder or namespace. - **MUST: inject every service, repository, resolver, and delegated executor through an interface, never a concrete implementation type.** Reuse an existing interface where available; otherwise define one alongside the implementation, implement it, and register the mapping in `IoCModule` (for example `builder.RegisterType().As()`). Apply this rule to supporting services' constructor dependencies as well. Delegate executors through `ICommandExecutor`. Do not instantiate dependencies or bypass injection with static methods that perform I/O; expose those operations through the injected interface. This is mandatory so executor unit tests can mock dependencies and run entirely locally. Pure static formatting/computation helpers are allowed. - Command infrastructure (`CommandRunnerBase`) validates DataAnnotations and `IValidatableObject` before calling an executor. Do not call `Validator.TryValidateObject` again in executors or duplicate command validation there. Declare input constraints on the command; keep only business checks requiring resolved data (for example whether a privilege supports a depth) in the executor. Tests for command validation belong in the command test file, not in tests that invoke the executor directly. - **Never** write to `Console` directly β€” always use `IOutput`. - If you need additional output formatting you can use `IAnsiConsole` from `Greg.Xrm.Command.Services.Output`, which wraps `Spectre.Console` functionality. - Inline progress pattern: `output.Write("Step…")` β†’ do async work β†’ `output.WriteLine(" Done", ConsoleColor.Green)`. - Highlight important user-provided values in yellow: `output.Write(command.SchemaName, ConsoleColor.Yellow)`. - Resolve omitted `--solution` via `organizationServiceRepository.GetCurrentDefaultSolutionAsync()`. - Catch `FaultException` at minimum. Catch `Exception` when non-Dataverse failures are possible. - Put non-trivial computation in `private static` helper methods. - Populate `CommandResult` with key/value output pairs for structured consumers. - `CommandResult` output values must be scalars (strings, numbers, booleans, GUIDs), never arrays or collection objects: the CLI renders them via `ToString()` and displays .NET type names instead of their contents. Render detailed collections through `IOutput.WriteTable` and return counts or explicitly formatted summary strings. Keep collection-based view models internal; tests can inspect service results or capture table rows, rather than requiring collections in command output parameters. --- ## Tests Once the command and executor files are in place, use the **`pacx-unit-test-writer`** skill to generate the corresponding test files. --- ## Checklist before finishing - [ ] `Command.cs` placed in `Greg.Xrm.Command.Core\Commands\\` - [ ] `[Command]` verbs are noun-first: `"", ""` - [ ] `[Alias]` added only when the reversed order is useful - [ ] All `[Option]` have `HelpText`, sensible `Order`, `DefaultValue` where applicable - [ ] Required options marked with `[Required]` - [ ] `IValidatableObject.Validate()` implemented only for cross-option constraints - [ ] `ICanProvideUsageExample.WriteUsageExamples()` implemented for non-trivial commands - [ ] `CommandExecutor.cs` placed in the same domain folder - [ ] Services and their interfaces live under `Services`; models/repositories and their interfaces live under `Model`, not in `Commands` - [ ] All executor and supporting-service constructor dependencies are interfaces, with implementation mappings registered in `IoCModule`; no concrete injected types or static I/O bypasses - [ ] Executor tests mock service/repository interfaces, and the executor resolves through IoC - [ ] Executor uses `IOutput` for all console output (never `Console.Write*`) - [ ] Progress pattern (`Write(…)` / `WriteLine(" Done", Green)`) used for each step - [ ] `FaultException` caught and mapped to `CommandResult.Fail` - [ ] `CommandResult` populated with key output values - [ ] `CommandResult` contains only scalar output values, no arrays or collections - [ ] Tests written using the **`pacx-unit-test-writer`** skill - [ ] `dotnet build` passes - [ ] `dotnet test Greg.Xrm.Command.Core.TestSuite\Greg.Xrm.Command.Core.TestSuite.csproj` passes