diff --git a/src/TALXIS.CLI.Core/Platforms/PowerPlatform/IConnectorCatalogService.cs b/src/TALXIS.CLI.Core/Platforms/PowerPlatform/IConnectorCatalogService.cs
new file mode 100644
index 00000000..edd19ad0
--- /dev/null
+++ b/src/TALXIS.CLI.Core/Platforms/PowerPlatform/IConnectorCatalogService.cs
@@ -0,0 +1,83 @@
+namespace TALXIS.CLI.Core.Platforms.PowerPlatform;
+
+///
+/// A connector available in a Power Platform environment (e.g. shared_teams).
+///
+public sealed record ConnectorSummary(
+ string Name,
+ string DisplayName,
+ string? Tier,
+ bool IsCustomApi,
+ string? Description);
+
+///
+/// One operation (action or trigger) exposed by a connector, distilled from
+/// its OpenAPI definition.
+///
+public sealed record ConnectorOperationSummary(
+ string OperationId,
+ string Kind,
+ string? Summary,
+ string? Visibility,
+ string? Description);
+
+///
+/// A single operation parameter. Body schema leaves are flattened into
+/// slash-joined names (e.g. item/subject) — exactly the key format a
+/// cloud flow definition uses in inputs.parameters.
+///
+public sealed record ConnectorOperationParameter(
+ string Name,
+ string In,
+ string? Type,
+ bool Required,
+ string? Summary,
+ string? Description,
+ IReadOnlyList? EnumValues,
+ bool IsDynamic);
+
+///
+/// Full parameter-level detail of one connector operation, ready to be copied
+/// into a cloud flow action (host.apiId, host.operationId,
+/// inputs.parameters keys).
+///
+public sealed record ConnectorOperationDetail(
+ string ConnectorName,
+ string ApiId,
+ string OperationId,
+ string Kind,
+ string? Summary,
+ string? Description,
+ string HttpMethod,
+ string Path,
+ IReadOnlyList Parameters);
+
+///
+/// Read-only discovery over the connectors available in the target
+/// environment and their operations, backed by the Power Apps connector API.
+/// Exists so agents authoring cloud flow definitions can look up exact
+/// operation ids and parameter names instead of guessing them.
+///
+public interface IConnectorCatalogService
+{
+ /// Lists connectors available in the profile's environment.
+ Task> ListConnectorsAsync(
+ string? profileName,
+ CancellationToken ct);
+
+ /// Lists all operations (actions and triggers) of one connector.
+ Task> ListOperationsAsync(
+ string? profileName,
+ string connectorName,
+ CancellationToken ct);
+
+ ///
+ /// Returns parameter-level detail for one operation, or null when the
+ /// operation does not exist on the connector.
+ ///
+ Task GetOperationAsync(
+ string? profileName,
+ string connectorName,
+ string operationId,
+ CancellationToken ct);
+}
diff --git a/src/TALXIS.CLI.Features.Docs/Skills/component-creation.md b/src/TALXIS.CLI.Features.Docs/Skills/component-creation.md
index 38eda305..ab877b47 100644
--- a/src/TALXIS.CLI.Features.Docs/Skills/component-creation.md
+++ b/src/TALXIS.CLI.Features.Docs/Skills/component-creation.md
@@ -30,12 +30,13 @@ Scaffold components in dependency order:
7. **Project Reference** — `dotnet add reference` from solution project to the separate project. The Build SDK auto-detects the project type and handles registration (assembly data.xml, web resource data.xml, etc.) during `dotnet build`
8. **Ribbon Buttons** (`pp-ribbon-button`) — after entity and script library exist. References the web resource via `LibraryLogicalName=prefix_name`
9. **Form Event Handlers** (`pp-form-event-handler`) — after entity, form, and script library exist. References via `libraryName=prefix_name.js` and `functionName=prefix_name.ClassName.methodName`
+10. **Cloud Flows** (`pp-flow`) - after any entity the flow triggers on exists. Dataverse-trigger flows reference an existing connection reference via `ConnectionReferenceLogicalName` (see [flow-development](flow-development.md))
## Key Parameter Conventions
-- **`LogicalName`** (in pp-entity, pp-entity-attribute, pp-optionset-global, pp-app-model): The name **without** publisher prefix. The template adds the prefix automatically. Example: `warehouseitem`, not `udpp_warehouseitem`.
+- **`LogicalName`** (in pp-entity, pp-entity-attribute, pp-optionset-global, pp-app-model, pp-flow): The name **without** publisher prefix. The template adds the prefix automatically. Example: `warehouseitem`, not `udpp_warehouseitem`.
- **`EntitySchemaName`** (in pp-entity-attribute, pp-entity-form, pp-entity-view, pp-form-*): The entity name **with** publisher prefix. Example: `udpp_warehouseitem`.
-- **`EntityLogicalName`** (in pp-sitemap-subarea, pp-app-model-component, pp-ribbon-*): The entity name **with** publisher prefix. Example: `udpp_warehouseitem`.
+- **`EntityLogicalName`** (in pp-sitemap-subarea, pp-app-model-component, pp-ribbon-*, pp-flow): The entity name **with** publisher prefix. Example: `udpp_warehouseitem`.
- **`AppName`** (in pp-sitemap-*, pp-app-model-component): The app module folder name **with** publisher prefix. Example: `udpp_warehouseapp`.
- **`ReferencedEntityName`** (in pp-entity-attribute for Lookup types): The target entity **with** publisher prefix. Example: `udpp_warehouseitem`.
- **`Behavior`** (in pp-entity): Use `New` when creating the entity definition for the first time. Use `Existing` to add a reference to an entity owned by another solution (e.g., adding forms/views in a UI solution for an entity defined in the DataModel solution).
diff --git a/src/TALXIS.CLI.Features.Docs/Skills/custom-api-development.md b/src/TALXIS.CLI.Features.Docs/Skills/custom-api-development.md
index 84f83e47..8b0f9bb2 100644
--- a/src/TALXIS.CLI.Features.Docs/Skills/custom-api-development.md
+++ b/src/TALXIS.CLI.Features.Docs/Skills/custom-api-development.md
@@ -8,7 +8,7 @@ Custom APIs are custom messages (actions) in Dataverse that expose reusable busi
- **Custom API** — reusable callable endpoint, typed request/response, synchronous with return value
- **Plugin** — reactive logic triggered by data events (Create, Update, Delete)
-- **Power Automate** — low-code automation with connectors and approval flows
+- **Power Automate** — low-code automation with connectors and approval flows (see [flow-development](flow-development.md))
Choose Custom API when multiple clients or flows need to invoke the same operation.
diff --git a/src/TALXIS.CLI.Features.Docs/Skills/flow-development.md b/src/TALXIS.CLI.Features.Docs/Skills/flow-development.md
new file mode 100644
index 00000000..1b0dec9c
--- /dev/null
+++ b/src/TALXIS.CLI.Features.Docs/Skills/flow-development.md
@@ -0,0 +1,48 @@
+# Cloud Flow Development
+
+## Key Concept
+
+Power Automate cloud flows are solution components stored locally as source: a JSON definition (Logic Apps workflow schema) under `Workflows/` plus a sibling `.data.xml` metadata file (`Category=5`, `Type=1`). Scaffolding creates the flow locally - it reaches the environment through the normal solution deployment, never by clicking it together in the designer first.
+
+## Flow Scaffolding Chain
+
+1. **Scaffold the flow** → `workspace_component_create` with `componentType: "pp-flow"` and a `Trigger` choice:
+ - `manual` - instant flow started by a button (default)
+ - `recurrence` - scheduled flow; set `RecurrenceFrequency` (Minute/Hour/Day/Week/Month) and `RecurrenceInterval`
+ - `dataverse` - automated flow on a Dataverse row event; set `EntityLogicalName`, `TriggerEvent`, `Scope`, optional `FilteringAttributes`, and `ConnectionReferenceLogicalName`
+2. **Author actions** by editing the generated `Workflows/_-.json` (rules below)
+3. **Build locally** to validate and pack: `dotnet build`
+4. Follow the [deployment workflow](deployment-workflow.md)
+
+Call `workspace_component_parameter_list` for required parameters at each step.
+
+## Dataverse Trigger Parameters
+
+- **`TriggerEvent`** → `subscriptionRequest/message`: create=1, delete=2, update=3, create-or-update=4, create-or-delete=5, update-or-delete=6, create-or-update-or-delete=7
+- **`Scope`** → `subscriptionRequest/scope`: user=1, business-unit=2, parent-child-business-unit=3, organization=4 (default)
+- **`FilteringAttributes`** - comma-separated attribute logical names; the flow only fires when one of them changes (meaningful for events that include update)
+- **`EntityLogicalName`** - entity logical name **with** publisher prefix (e.g. `udpp_warehouseitem`)
+- **`ConnectionReferenceLogicalName`** - logical name of an **existing** Dataverse connection reference; connection references are declared under `` in `Other/Customizations.xml`
+
+## Authoring Actions in the Definition JSON
+
+- The definition must declare both `$connections` and `$authentication` parameters (the template already does)
+- Standard connector actions use `"type": "OpenApiConnection"` (never `ApiConnection`); webhook-style operations (e.g. Approvals) use `"type": "OpenApiConnectionWebhook"`
+- Every connector action's `host.connectionName` must match a key in `properties.connectionReferences`; each entry needs `"runtimeSource": "embedded"` and a real `connectionReferenceLogicalName`
+- `runAfter` must reference existing action names; the first action uses `"runAfter": {}`
+- Every `OpenApiConnection`/`OpenApiConnectionWebhook` trigger and action passes `"authentication": "@parameters('$authentication')"` inside `inputs` (real exported flows carry it for all connectors, not just Dataverse)
+- Never guess connector `operationId`, `apiId`, or parameter names - discover them live: `environment_connector_list` (available connectors), `environment_connector_operation_list` (operations with kind action/trigger/webhook-trigger), `environment_connector_operation_get` (exact parameter names, types, enums; body leaves are already slash-joined like `emailMessage/To`)
+- `webhook-trigger` operations go into `triggers` as `OpenApiConnectionWebhook`; parameters marked dynamic require values resolved at authoring time
+- Dataverse `CreateRecord`/`UpdateRecord` bodies are dynamic: keys are `item/{attribute logical name}` from the entity schema, with the `@odata.bind` suffix for lookups (e.g. `item/ownerid@odata.bind`)
+- Expressions use `@{...}` interpolation with functions like `triggerOutputs()`, `outputs('')?['body/field']`, `concat()`, `utcNow()`
+
+## What NOT to Do
+
+- ❌ Don't create flows directly in the environment - scaffold locally so the flow lives in source control
+- ❌ Don't scaffold a `dataverse`-trigger flow before the triggering entity exists in the workspace
+- ❌ Don't reference a connection reference that doesn't exist - declare it in `Other/Customizations.xml` first
+- ❌ Don't invent connector operation parameters - mirror a real flow or a designer export
+- ❌ Don't use premium triggers (e.g. HTTP request) without checking licensing; if a DLP policy blocks a trigger connector, fall back to a `recurrence` trigger that polls instead
+- ❌ Don't rename the flow JSON file by hand - `JsonFileName` inside the `.data.xml` must keep matching it exactly
+
+See also: [component-creation](component-creation.md), [deployment-workflow](deployment-workflow.md)
diff --git a/src/TALXIS.CLI.Features.Docs/Skills/index.json b/src/TALXIS.CLI.Features.Docs/Skills/index.json
index 67ecc807..82d94732 100644
--- a/src/TALXIS.CLI.Features.Docs/Skills/index.json
+++ b/src/TALXIS.CLI.Features.Docs/Skills/index.json
@@ -14,5 +14,6 @@
{"id": "pcf-controls", "title": "PCF Control Development", "summary": "PCF project structure, ControlManifest, lifecycle methods, dataset vs field controls, and build workflow.", "tags": ["workspace", "local-development", "pcf"]},
{"id": "build-errors", "title": "Build Error Recovery", "summary": "Diagnose and fix TALXISXSD001, TALXISGUID001, and other build validation errors.", "tags": ["troubleshooting", "build", "validation"]},
{"id": "data-querying", "title": "Data Querying", "summary": "Query Dataverse data using SQL, OData, and FetchXML — choosing the right language, OData patterns, aggregation, and pagination.", "tags": ["data-operations", "environment"]},
- {"id": "security-roles", "title": "Security Role Scaffolding", "summary": "Scaffold security roles, add privileges, and assign roles to model-driven apps.", "tags": ["workspace", "local-development", "security"]}
+ {"id": "security-roles", "title": "Security Role Scaffolding", "summary": "Scaffold security roles, add privileges, and assign roles to model-driven apps.", "tags": ["workspace", "local-development", "security"]},
+ {"id": "flow-development", "title": "Cloud Flow Development", "summary": "Power Automate cloud flow structure, trigger selection, connection references, action authoring rules, and scaffolding with pp-flow.", "tags": ["workspace", "local-development", "flows"]}
]
diff --git a/src/TALXIS.CLI.Features.Environment/Connector/ConnectorCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Connector/ConnectorCliCommand.cs
new file mode 100644
index 00000000..d329d5ae
--- /dev/null
+++ b/src/TALXIS.CLI.Features.Environment/Connector/ConnectorCliCommand.cs
@@ -0,0 +1,21 @@
+using DotMake.CommandLine;
+
+namespace TALXIS.CLI.Features.Environment.Connector;
+
+[CliCommand(
+ Name = "connector",
+ Description = "Discover connectors and their operations in the live environment (for authoring cloud flows).",
+ Children = new[]
+ {
+ typeof(ConnectorListCliCommand),
+ typeof(Operation.ConnectorOperationCliCommand),
+ },
+ ShortFormAutoGenerate = CliNameAutoGenerate.None
+)]
+public class ConnectorCliCommand
+{
+ public void Run(CliContext context)
+ {
+ context.ShowHelp();
+ }
+}
diff --git a/src/TALXIS.CLI.Features.Environment/Connector/ConnectorListCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Connector/ConnectorListCliCommand.cs
new file mode 100644
index 00000000..2c452755
--- /dev/null
+++ b/src/TALXIS.CLI.Features.Environment/Connector/ConnectorListCliCommand.cs
@@ -0,0 +1,67 @@
+using DotMake.CommandLine;
+using Microsoft.Extensions.Logging;
+using TALXIS.CLI.Core;
+using TALXIS.CLI.Core.DependencyInjection;
+using TALXIS.CLI.Core.Platforms.PowerPlatform;
+using TALXIS.CLI.Logging;
+
+namespace TALXIS.CLI.Features.Environment.Connector;
+
+[CliReadOnly]
+[CliCommand(
+ Name = "list",
+ Description = "List the connectors available in the profile's environment."
+)]
+public class ConnectorListCliCommand : ProfiledCliCommand
+{
+ protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(ConnectorListCliCommand));
+
+ [CliOption(Name = "--filter", Description = "Show only connectors whose name or display name contains this substring.", Required = false)]
+ public string? Filter { get; set; }
+
+ protected override async Task ExecuteAsync()
+ {
+ var service = TxcServices.Get();
+ IReadOnlyList connectors = await service.ListConnectorsAsync(Profile, CancellationToken.None)
+ .ConfigureAwait(false);
+
+ if (!string.IsNullOrWhiteSpace(Filter))
+ {
+ connectors = connectors
+ .Where(c => Contains(c.Name, Filter) || Contains(c.DisplayName, Filter))
+ .ToList();
+ }
+
+ OutputFormatter.WriteList(connectors, PrintTable);
+ return ExitSuccess;
+ }
+
+ private static bool Contains(string? value, string substring)
+ => value is not null && value.Contains(substring, StringComparison.OrdinalIgnoreCase);
+
+ // Text-renderer callback invoked by OutputFormatter.WriteList — OutputWriter usage is intentional.
+#pragma warning disable TXC003
+ private static void PrintTable(IReadOnlyList connectors)
+ {
+ if (connectors.Count == 0)
+ {
+ OutputWriter.WriteLine("No connectors found.");
+ return;
+ }
+
+ int nameWidth = Math.Clamp(connectors.Max(c => c.Name.Length), 20, 60);
+ int displayWidth = Math.Clamp(connectors.Max(c => c.DisplayName.Length), 12, 40);
+ string header = $"{"Name".PadRight(nameWidth)} | {"Display Name".PadRight(displayWidth)} | {"Tier".PadRight(10)} | Custom";
+ OutputWriter.WriteLine(header);
+ OutputWriter.WriteLine(new string('-', header.Length));
+
+ foreach (var c in connectors)
+ {
+ string name = c.Name.Length > nameWidth ? c.Name[..(nameWidth - 1)] + "." : c.Name;
+ string display = c.DisplayName.Length > displayWidth ? c.DisplayName[..(displayWidth - 1)] + "." : c.DisplayName;
+ OutputWriter.WriteLine(
+ $"{name.PadRight(nameWidth)} | {display.PadRight(displayWidth)} | {(c.Tier ?? string.Empty).PadRight(10)} | {(c.IsCustomApi ? "yes" : string.Empty)}");
+ }
+ }
+#pragma warning restore TXC003
+}
diff --git a/src/TALXIS.CLI.Features.Environment/Connector/Operation/ConnectorOperationCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Connector/Operation/ConnectorOperationCliCommand.cs
new file mode 100644
index 00000000..7088fd2e
--- /dev/null
+++ b/src/TALXIS.CLI.Features.Environment/Connector/Operation/ConnectorOperationCliCommand.cs
@@ -0,0 +1,21 @@
+using DotMake.CommandLine;
+
+namespace TALXIS.CLI.Features.Environment.Connector.Operation;
+
+[CliCommand(
+ Name = "operation",
+ Description = "Inspect the operations (actions and triggers) a connector exposes.",
+ Children = new[]
+ {
+ typeof(ConnectorOperationListCliCommand),
+ typeof(ConnectorOperationGetCliCommand),
+ },
+ ShortFormAutoGenerate = CliNameAutoGenerate.None
+)]
+public class ConnectorOperationCliCommand
+{
+ public void Run(CliContext context)
+ {
+ context.ShowHelp();
+ }
+}
diff --git a/src/TALXIS.CLI.Features.Environment/Connector/Operation/ConnectorOperationGetCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Connector/Operation/ConnectorOperationGetCliCommand.cs
new file mode 100644
index 00000000..166d465b
--- /dev/null
+++ b/src/TALXIS.CLI.Features.Environment/Connector/Operation/ConnectorOperationGetCliCommand.cs
@@ -0,0 +1,84 @@
+using DotMake.CommandLine;
+using Microsoft.Extensions.Logging;
+using TALXIS.CLI.Core;
+using TALXIS.CLI.Core.DependencyInjection;
+using TALXIS.CLI.Core.Platforms.PowerPlatform;
+using TALXIS.CLI.Logging;
+
+namespace TALXIS.CLI.Features.Environment.Connector.Operation;
+
+[CliReadOnly]
+[CliCommand(
+ Name = "get",
+ Description = "Show one connector operation with its exact parameter names, types, and enum values for flow authoring."
+)]
+public class ConnectorOperationGetCliCommand : ProfiledCliCommand
+{
+ protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(ConnectorOperationGetCliCommand));
+
+ [CliOption(Name = "--connector", Description = "Connector name as returned by 'connector list' (e.g. shared_commondataserviceforapps).", Required = true)]
+ public string Connector { get; set; } = null!;
+
+ [CliOption(Name = "--operation", Description = "Operation id as returned by 'connector operation list' (e.g. CreateRecord).", Required = true)]
+ public string Operation { get; set; } = null!;
+
+ protected override async Task ExecuteAsync()
+ {
+ var service = TxcServices.Get();
+ ConnectorOperationDetail? detail = await service
+ .GetOperationAsync(Profile, Connector, Operation, CancellationToken.None)
+ .ConfigureAwait(false);
+
+ if (detail is null)
+ {
+ Logger.LogError(
+ "Operation '{Operation}' was not found on connector '{Connector}'. Use 'txc environment connector operation list --connector {Connector}' to see available operations.",
+ Operation, Connector, Connector);
+ return ExitValidationError;
+ }
+
+ OutputFormatter.WriteData(detail, PrintDetail);
+ return ExitSuccess;
+ }
+
+ // Text-renderer callback invoked by OutputFormatter.WriteData — OutputWriter usage is intentional.
+#pragma warning disable TXC003
+ private static void PrintDetail(ConnectorOperationDetail d)
+ {
+ const int labelWidth = -14;
+ OutputWriter.WriteLine($"{"Operation:",labelWidth}{d.OperationId}");
+ OutputWriter.WriteLine($"{"Connector:",labelWidth}{d.ConnectorName}");
+ OutputWriter.WriteLine($"{"Api Id:",labelWidth}{d.ApiId}");
+ OutputWriter.WriteLine($"{"Kind:",labelWidth}{d.Kind}");
+ OutputWriter.WriteLine($"{"Endpoint:",labelWidth}{d.HttpMethod} {d.Path}");
+ if (!string.IsNullOrWhiteSpace(d.Summary))
+ OutputWriter.WriteLine($"{"Summary:",labelWidth}{d.Summary}");
+
+ OutputWriter.WriteLine(string.Empty);
+
+ if (d.Parameters.Count == 0)
+ {
+ OutputWriter.WriteLine("No parameters.");
+ return;
+ }
+
+ int nameWidth = Math.Clamp(d.Parameters.Max(p => p.Name.Length), 20, 60);
+ int typeWidth = 18;
+ string header = $"{"Parameter".PadRight(nameWidth)} | {"In".PadRight(6)} | {"Type".PadRight(typeWidth)} | {"Req".PadRight(3)} | {"Dyn".PadRight(3)} | Summary";
+ OutputWriter.WriteLine(header);
+ OutputWriter.WriteLine(new string('-', header.Length));
+
+ foreach (var p in d.Parameters)
+ {
+ string name = p.Name.Length > nameWidth ? p.Name[..(nameWidth - 1)] + "." : p.Name;
+ string type = p.Type ?? string.Empty;
+ type = type.Length > typeWidth ? type[..(typeWidth - 1)] + "." : type;
+ string summary = p.Summary ?? string.Empty;
+ if (p.EnumValues is { Count: > 0 })
+ summary = $"{summary} [{string.Join(", ", p.EnumValues)}]".Trim();
+ OutputWriter.WriteLine(
+ $"{name.PadRight(nameWidth)} | {p.In.PadRight(6)} | {type.PadRight(typeWidth)} | {(p.Required ? "yes" : "").PadRight(3)} | {(p.IsDynamic ? "yes" : "").PadRight(3)} | {summary}");
+ }
+ }
+#pragma warning restore TXC003
+}
diff --git a/src/TALXIS.CLI.Features.Environment/Connector/Operation/ConnectorOperationListCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Connector/Operation/ConnectorOperationListCliCommand.cs
new file mode 100644
index 00000000..2c943912
--- /dev/null
+++ b/src/TALXIS.CLI.Features.Environment/Connector/Operation/ConnectorOperationListCliCommand.cs
@@ -0,0 +1,75 @@
+using DotMake.CommandLine;
+using Microsoft.Extensions.Logging;
+using TALXIS.CLI.Core;
+using TALXIS.CLI.Core.DependencyInjection;
+using TALXIS.CLI.Core.Platforms.PowerPlatform;
+using TALXIS.CLI.Logging;
+
+namespace TALXIS.CLI.Features.Environment.Connector.Operation;
+
+[CliReadOnly]
+[CliCommand(
+ Name = "list",
+ Description = "List the operations (actions and triggers) of a connector in the profile's environment."
+)]
+public class ConnectorOperationListCliCommand : ProfiledCliCommand
+{
+ protected override ILogger Logger { get; } = TxcLoggerFactory.CreateLogger(nameof(ConnectorOperationListCliCommand));
+
+ [CliOption(Name = "--connector", Description = "Connector name as returned by 'connector list' (e.g. shared_commondataserviceforapps).", Required = true)]
+ public string Connector { get; set; } = null!;
+
+ [CliOption(Name = "--kind", Description = "Show only operations of this kind (action, trigger, webhook-trigger).", Required = false)]
+ public string? Kind { get; set; }
+
+ private static readonly string[] KnownKinds = ["action", "trigger", "webhook-trigger"];
+
+ protected override async Task ExecuteAsync()
+ {
+ if (!string.IsNullOrWhiteSpace(Kind) && !KnownKinds.Contains(Kind, StringComparer.OrdinalIgnoreCase))
+ {
+ Logger.LogError(
+ "Invalid --kind '{Kind}'. Expected one of: action, trigger, webhook-trigger.", Kind);
+ return ExitValidationError;
+ }
+
+ var service = TxcServices.Get();
+ IReadOnlyList operations = await service
+ .ListOperationsAsync(Profile, Connector, CancellationToken.None)
+ .ConfigureAwait(false);
+
+ if (!string.IsNullOrWhiteSpace(Kind))
+ {
+ operations = operations
+ .Where(o => string.Equals(o.Kind, Kind, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+ }
+
+ OutputFormatter.WriteList(operations, PrintTable);
+ return ExitSuccess;
+ }
+
+ // Text-renderer callback invoked by OutputFormatter.WriteList — OutputWriter usage is intentional.
+#pragma warning disable TXC003
+ private static void PrintTable(IReadOnlyList operations)
+ {
+ if (operations.Count == 0)
+ {
+ OutputWriter.WriteLine("No operations found.");
+ return;
+ }
+
+ int idWidth = Math.Clamp(operations.Max(o => o.OperationId.Length), 20, 50);
+ string header = $"{"Operation Id".PadRight(idWidth)} | {"Kind".PadRight(15)} | Summary";
+ OutputWriter.WriteLine(header);
+ OutputWriter.WriteLine(new string('-', header.Length));
+
+ foreach (var o in operations)
+ {
+ string id = o.OperationId.Length > idWidth ? o.OperationId[..(idWidth - 1)] + "." : o.OperationId;
+ OutputWriter.WriteLine(
+ $"{id.PadRight(idWidth)} | {o.Kind.PadRight(15)} | {o.Summary}");
+ }
+ }
+#pragma warning restore TXC003
+}
diff --git a/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs b/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs
index 3e26fc7f..65124c19 100644
--- a/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs
+++ b/src/TALXIS.CLI.Features.Environment/EnvironmentCliCommand.cs
@@ -6,7 +6,7 @@ namespace TALXIS.CLI.Features.Environment;
Name = "environment",
Alias = "env",
Description = "Manage the footprint of your project in a live target environment (packages, solutions, deployment history).",
- Children = new[] { typeof(EnvironmentListCliCommand), typeof(EnvironmentCreateCliCommand), typeof(EnvironmentUpdateCliCommand), typeof(EnvironmentDeleteCliCommand), typeof(Package.PackageCliCommand), typeof(Solution.SolutionCliCommand), typeof(Deployment.DeploymentCliCommand), typeof(Data.EnvDataCliCommand), typeof(Entity.EntityCliCommand), typeof(OptionSet.OptionSetCliCommand), typeof(Setting.SettingCliCommand), typeof(Changeset.ChangesetCliCommand), typeof(Component.ComponentCliCommand), typeof(Publisher.PublisherCliCommand) },
+ Children = new[] { typeof(EnvironmentListCliCommand), typeof(EnvironmentCreateCliCommand), typeof(EnvironmentUpdateCliCommand), typeof(EnvironmentDeleteCliCommand), typeof(Package.PackageCliCommand), typeof(Solution.SolutionCliCommand), typeof(Deployment.DeploymentCliCommand), typeof(Data.EnvDataCliCommand), typeof(Entity.EntityCliCommand), typeof(OptionSet.OptionSetCliCommand), typeof(Setting.SettingCliCommand), typeof(Changeset.ChangesetCliCommand), typeof(Component.ComponentCliCommand), typeof(Publisher.PublisherCliCommand), typeof(Connector.ConnectorCliCommand) },
ShortFormAutoGenerate = CliNameAutoGenerate.None
)]
public class EnvironmentCliCommand
diff --git a/src/TALXIS.CLI.MCP/Skills/Internal/component-composition-chains.md b/src/TALXIS.CLI.MCP/Skills/Internal/component-composition-chains.md
index baa587ef..46faf0c3 100644
--- a/src/TALXIS.CLI.MCP/Skills/Internal/component-composition-chains.md
+++ b/src/TALXIS.CLI.MCP/Skills/Internal/component-composition-chains.md
@@ -30,6 +30,11 @@
1. BPF entity → 2. BPFStage (repeat) → 3. BPFStageStep (repeat per stage)
- Entity must have IsBPFEntity=1; XAML workflow needs Category=4, TriggerOnCreate=1
+## Flow Chain
+1. Flow (pp-flow, pick Trigger: manual/recurrence/dataverse) → 2. Edit definition JSON to add actions
+- Dataverse trigger: entity MUST exist; ConnectionReferenceLogicalName MUST point to an existing connection reference in Other/Customizations.xml
+- See flow-development skill for trigger parameters and action JSON rules
+
## Local vs Live
- ALL chains above: use workspace tools (local, instant, reversible)
- Environment tools ONLY for: inspection, layer troubleshooting, import, publish
diff --git a/src/TALXIS.CLI.Platform.Dataverse.Runtime/DependencyInjection/DataverseProviderServiceCollectionExtensions.cs b/src/TALXIS.CLI.Platform.Dataverse.Runtime/DependencyInjection/DataverseProviderServiceCollectionExtensions.cs
index fa78126c..0ae0a8d3 100644
--- a/src/TALXIS.CLI.Platform.Dataverse.Runtime/DependencyInjection/DataverseProviderServiceCollectionExtensions.cs
+++ b/src/TALXIS.CLI.Platform.Dataverse.Runtime/DependencyInjection/DataverseProviderServiceCollectionExtensions.cs
@@ -58,6 +58,8 @@ public static IServiceCollection AddTxcDataverseProvider(this IServiceCollection
EnvironmentSettingsService>();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
diff --git a/src/TALXIS.CLI.Platform.PowerPlatform.Control/ConnectorCatalogService.cs b/src/TALXIS.CLI.Platform.PowerPlatform.Control/ConnectorCatalogService.cs
new file mode 100644
index 00000000..99e32b1c
--- /dev/null
+++ b/src/TALXIS.CLI.Platform.PowerPlatform.Control/ConnectorCatalogService.cs
@@ -0,0 +1,273 @@
+using System.Net;
+using System.Text.Json;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using TALXIS.CLI.Core.Abstractions;
+using TALXIS.CLI.Core.Model;
+using TALXIS.CLI.Core.Platforms.PowerPlatform;
+using TALXIS.CLI.Platform.PowerPlatform.Control.Bap;
+using TALXIS.CLI.Platform.PowerPlatform.Control.PowerApps;
+
+namespace TALXIS.CLI.Platform.PowerPlatform.Control;
+
+///
+/// Discovers connectors and their operations in the profile's environment via
+/// the Power Apps connector API. Reuses the BAP transport because both APIs
+/// validate the same service.powerapps.com audience, so one cached
+/// token serves both.
+///
+public sealed class ConnectorCatalogService : IConnectorCatalogService
+{
+ private readonly IConfigurationResolver _resolver;
+ private readonly IPowerPlatformEnvironmentCatalog _catalog;
+ private readonly BapAdminApiClient _client;
+ private readonly IHttpClientFactoryWrapper _httpFactory;
+ private readonly ILogger _logger;
+
+ public ConnectorCatalogService(
+ IConfigurationResolver resolver,
+ IPowerPlatformEnvironmentCatalog catalog,
+ IAccessTokenService tokens,
+ IHttpClientFactoryWrapper? httpFactory = null,
+ ILoggerFactory? loggerFactory = null)
+ {
+ _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
+ _catalog = catalog ?? throw new ArgumentNullException(nameof(catalog));
+ _client = new BapAdminApiClient(tokens, httpFactory);
+ _httpFactory = httpFactory ?? DefaultHttpClientFactoryWrapper.Instance;
+ _logger = loggerFactory?.CreateLogger()
+ ?? NullLogger.Instance;
+ }
+
+ public async Task> ListConnectorsAsync(
+ string? profileName, CancellationToken ct)
+ {
+ var (ctx, baseUri, environmentId) = await PrepareAsync(profileName, ct).ConfigureAwait(false);
+ var token = await _client.AcquireTokenAsync(ctx.Connection, ctx.Credential, ct).ConfigureAwait(false);
+
+ var connectors = new List();
+ Uri? nextPage = new(baseUri,
+ $"providers/Microsoft.PowerApps/apis?api-version={PowerAppsEndpointProvider.ApiVersion}&$filter={BuildEnvironmentFilter(environmentId)}");
+
+ while (nextPage is not null)
+ {
+ var response = await _client.SendAsync(HttpMethod.Get, nextPage, token, jsonBody: null, ct).ConfigureAwait(false);
+ if (!response.IsSuccess)
+ {
+ throw new InvalidOperationException(
+ $"Power Apps connector lookup failed ({(int)response.StatusCode} {response.StatusCode}) against '{nextPage}': {BapAdminApiClient.Truncate(response.Body, 500)}");
+ }
+
+ using var document = JsonDocument.Parse(response.Body);
+ var root = document.RootElement;
+ if (!root.TryGetProperty("value", out var items) || items.ValueKind != JsonValueKind.Array)
+ throw new InvalidOperationException("Power Apps connector lookup returned a payload without a 'value' array.");
+
+ foreach (var item in items.EnumerateArray())
+ {
+ if (TryParseConnector(item, out var connector))
+ connectors.Add(connector);
+ }
+
+ nextPage = TryReadNextLink(root, baseUri);
+ }
+
+ return connectors
+ .OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+
+ public async Task> ListOperationsAsync(
+ string? profileName, string connectorName, CancellationToken ct)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(connectorName);
+
+ var swagger = await GetSwaggerAsync(profileName, connectorName, ct).ConfigureAwait(false);
+ return ConnectorSwaggerParser.ListOperations(swagger);
+ }
+
+ public async Task GetOperationAsync(
+ string? profileName, string connectorName, string operationId, CancellationToken ct)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(connectorName);
+ ArgumentException.ThrowIfNullOrWhiteSpace(operationId);
+
+ var swagger = await GetSwaggerAsync(profileName, connectorName, ct).ConfigureAwait(false);
+ return ConnectorSwaggerParser.GetOperation(swagger, connectorName, BuildApiId(connectorName), operationId);
+ }
+
+ private async Task GetSwaggerAsync(string? profileName, string connectorName, CancellationToken ct)
+ {
+ var (ctx, baseUri, environmentId) = await PrepareAsync(profileName, ct).ConfigureAwait(false);
+ var token = await _client.AcquireTokenAsync(ctx.Connection, ctx.Credential, ct).ConfigureAwait(false);
+
+ var requestUri = new Uri(baseUri,
+ $"providers/Microsoft.PowerApps/apis/{Uri.EscapeDataString(connectorName)}?api-version={PowerAppsEndpointProvider.ApiVersion}&$expand=properties.swagger&$filter={BuildEnvironmentFilter(environmentId)}");
+
+ var response = await _client.SendAsync(HttpMethod.Get, requestUri, token, jsonBody: null, ct).ConfigureAwait(false);
+ if (response.StatusCode == HttpStatusCode.NotFound)
+ {
+ throw new InvalidOperationException(
+ $"Connector '{connectorName}' was not found in the environment. Verify the connector name with 'txc environment connector list'.");
+ }
+
+ if (!response.IsSuccess)
+ {
+ throw new InvalidOperationException(
+ $"Power Apps connector lookup for '{connectorName}' failed ({(int)response.StatusCode} {response.StatusCode}) against '{requestUri}': {BapAdminApiClient.Truncate(response.Body, 500)}");
+ }
+
+ using (var document = JsonDocument.Parse(response.Body))
+ {
+ if (document.RootElement.ValueKind == JsonValueKind.Object
+ && document.RootElement.TryGetProperty("properties", out var properties)
+ && properties.ValueKind == JsonValueKind.Object)
+ {
+ if (properties.TryGetProperty("swagger", out var swagger) && swagger.ValueKind == JsonValueKind.Object)
+ return swagger.Clone();
+
+ // Some connectors omit the inline swagger even with $expand; their
+ // definition is only reachable through the apiDefinitions blob URL.
+ if (properties.TryGetProperty("apiDefinitions", out var definitions)
+ && definitions.ValueKind == JsonValueKind.Object
+ && definitions.TryGetProperty("originalSwaggerUrl", out var swaggerUrl)
+ && swaggerUrl.ValueKind == JsonValueKind.String
+ && Uri.TryCreate(swaggerUrl.GetString(), UriKind.Absolute, out var blobUri))
+ {
+ _logger.LogInformation(
+ "Connector {Connector} has no inline swagger. Downloading definition from apiDefinitions.",
+ connectorName);
+ return await DownloadSwaggerAsync(blobUri, ct).ConfigureAwait(false);
+ }
+ }
+ }
+
+ throw new InvalidOperationException(
+ $"Connector '{connectorName}' returned no OpenAPI definition. Verify the connector name with 'txc environment connector list'.");
+ }
+
+ private async Task DownloadSwaggerAsync(Uri blobUri, CancellationToken ct)
+ {
+ // The apiDefinitions URL is a pre-signed blob link; an Authorization
+ // header would make Azure Storage reject the request, so plain GET.
+ using var http = _httpFactory.Create();
+ using var response = await http.GetAsync(blobUri, ct).ConfigureAwait(false);
+ var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
+ if (!response.IsSuccessStatusCode)
+ {
+ throw new InvalidOperationException(
+ $"Downloading the connector OpenAPI definition failed ({(int)response.StatusCode} {response.StatusCode}) against '{blobUri}': {BapAdminApiClient.Truncate(body, 500)}");
+ }
+
+ using var document = JsonDocument.Parse(body);
+ if (document.RootElement.ValueKind != JsonValueKind.Object)
+ {
+ throw new InvalidOperationException(
+ $"The connector OpenAPI definition downloaded from '{blobUri}' is not a JSON object.");
+ }
+
+ return document.RootElement.Clone();
+ }
+
+ private async Task<(ResolvedProfileContext Context, Uri BaseUri, Guid EnvironmentId)> PrepareAsync(
+ string? profileName, CancellationToken ct)
+ {
+ var ctx = await _resolver.ResolveAsync(profileName, ct).ConfigureAwait(false);
+ var baseUri = PowerAppsEndpointProvider.GetApiBaseUri(ctx.Connection.Cloud ?? CloudInstance.Public);
+ var environmentId = await ResolveEnvironmentIdAsync(ctx, ct).ConfigureAwait(false);
+ return (ctx, baseUri, environmentId);
+ }
+
+ private async Task ResolveEnvironmentIdAsync(ResolvedProfileContext ctx, CancellationToken ct)
+ {
+ if (ctx.Connection.EnvironmentId.HasValue)
+ return ctx.Connection.EnvironmentId.Value;
+
+ if (string.IsNullOrWhiteSpace(ctx.Connection.EnvironmentUrl)
+ || !Uri.TryCreate(ctx.Connection.EnvironmentUrl, UriKind.Absolute, out var envUri))
+ {
+ throw new InvalidOperationException(
+ $"Connection '{ctx.Connection.Id}' has no EnvironmentUrl or EnvironmentId.");
+ }
+
+ _logger.LogInformation(
+ "EnvironmentId not stored on connection '{ConnectionId}'. Resolving via Power Platform catalog...",
+ ctx.Connection.Id);
+
+ var env = await _catalog
+ .TryGetByEnvironmentUrlAsync(ctx.Connection, ctx.Credential, envUri, ct)
+ .ConfigureAwait(false);
+
+ if (env is null)
+ {
+ throw new InvalidOperationException(
+ $"Could not resolve Power Platform environment for URL '{ctx.Connection.EnvironmentUrl}'.");
+ }
+
+ return env.EnvironmentId;
+ }
+
+ private static string BuildEnvironmentFilter(Guid environmentId)
+ => Uri.EscapeDataString($"environment eq '{environmentId:D}'");
+
+ private static string BuildApiId(string connectorName)
+ => $"/providers/Microsoft.PowerApps/apis/{connectorName}";
+
+ private static bool TryParseConnector(JsonElement item, out ConnectorSummary connector)
+ {
+ connector = null!;
+
+ if (!item.TryGetProperty("name", out var nameElement)
+ || nameElement.ValueKind != JsonValueKind.String
+ || string.IsNullOrWhiteSpace(nameElement.GetString()))
+ {
+ return false;
+ }
+
+ string? displayName = null;
+ string? tier = null;
+ string? description = null;
+ var isCustomApi = false;
+
+ if (item.TryGetProperty("properties", out var properties) && properties.ValueKind == JsonValueKind.Object)
+ {
+ displayName = TryReadOptionalString(properties, "displayName");
+ tier = TryReadOptionalString(properties, "tier");
+ description = TryReadOptionalString(properties, "description");
+ isCustomApi = properties.TryGetProperty("isCustomApi", out var custom)
+ && (custom.ValueKind == JsonValueKind.True
+ || (custom.ValueKind == JsonValueKind.String
+ && bool.TryParse(custom.GetString(), out var parsed) && parsed));
+ }
+
+ var name = nameElement.GetString()!.Trim();
+ connector = new ConnectorSummary(
+ Name: name,
+ DisplayName: displayName ?? name,
+ Tier: tier,
+ IsCustomApi: isCustomApi,
+ Description: description);
+ return true;
+ }
+
+ private static Uri? TryReadNextLink(JsonElement root, Uri baseUri)
+ {
+ if (!root.TryGetProperty("nextLink", out var nextLinkElement)
+ || nextLinkElement.ValueKind != JsonValueKind.String
+ || string.IsNullOrWhiteSpace(nextLinkElement.GetString()))
+ {
+ return null;
+ }
+
+ var nextLink = nextLinkElement.GetString()!;
+ if (Uri.TryCreate(nextLink, UriKind.Absolute, out var absolute))
+ return absolute;
+
+ return Uri.TryCreate(baseUri, nextLink, out var relative) ? relative : null;
+ }
+
+ private static string? TryReadOptionalString(JsonElement element, string property)
+ => element.TryGetProperty(property, out var propertyElement) && propertyElement.ValueKind == JsonValueKind.String
+ ? propertyElement.GetString()?.Trim()
+ : null;
+}
diff --git a/src/TALXIS.CLI.Platform.PowerPlatform.Control/PowerApps/ConnectorSwaggerParser.cs b/src/TALXIS.CLI.Platform.PowerPlatform.Control/PowerApps/ConnectorSwaggerParser.cs
new file mode 100644
index 00000000..8d2bae7d
--- /dev/null
+++ b/src/TALXIS.CLI.Platform.PowerPlatform.Control/PowerApps/ConnectorSwaggerParser.cs
@@ -0,0 +1,375 @@
+using System.Text.Json;
+using TALXIS.CLI.Core.Platforms.PowerPlatform;
+
+namespace TALXIS.CLI.Platform.PowerPlatform.Control.PowerApps;
+
+///
+/// Distills a connector's OpenAPI 2.0 document into the small operation and
+/// parameter model that flow-authoring agents need. Body schemas are flattened
+/// into slash-joined leaf names (item/subject) because that is the key
+/// format cloud flow definitions use in inputs.parameters.
+///
+internal static class ConnectorSwaggerParser
+{
+ private static readonly string[] HttpVerbs = ["get", "put", "post", "delete", "patch", "head", "options"];
+
+ // Body schemas can nest arbitrarily; flow parameter keys rarely go deeper.
+ private const int MaxFlattenDepth = 4;
+
+ public static IReadOnlyList ListOperations(JsonElement swagger)
+ {
+ var operations = new List();
+
+ foreach (var (_, pathItem, _, operation) in EnumerateOperations(swagger))
+ {
+ if (!TryReadString(operation, "operationId", out var operationId))
+ continue;
+
+ operations.Add(new ConnectorOperationSummary(
+ OperationId: operationId,
+ Kind: ResolveKind(pathItem, operation),
+ Summary: TryReadOptionalString(operation, "summary"),
+ Visibility: TryReadOptionalString(operation, "x-ms-visibility"),
+ Description: TryReadOptionalString(operation, "description")));
+ }
+
+ return operations;
+ }
+
+ public static ConnectorOperationDetail? GetOperation(
+ JsonElement swagger,
+ string connectorName,
+ string apiId,
+ string operationId)
+ {
+ foreach (var (path, pathItem, verb, operation) in EnumerateOperations(swagger))
+ {
+ if (!TryReadString(operation, "operationId", out var candidate)
+ || !string.Equals(candidate, operationId, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ return new ConnectorOperationDetail(
+ ConnectorName: connectorName,
+ ApiId: apiId,
+ OperationId: candidate,
+ Kind: ResolveKind(pathItem, operation),
+ Summary: TryReadOptionalString(operation, "summary"),
+ Description: TryReadOptionalString(operation, "description"),
+ HttpMethod: verb.ToUpperInvariant(),
+ Path: path,
+ Parameters: CollectParameters(swagger, pathItem, operation));
+ }
+
+ return null;
+ }
+
+ private static IEnumerable<(string Path, JsonElement PathItem, string Verb, JsonElement Operation)> EnumerateOperations(
+ JsonElement swagger)
+ {
+ if (!swagger.TryGetProperty("paths", out var paths) || paths.ValueKind != JsonValueKind.Object)
+ yield break;
+
+ foreach (var pathProperty in paths.EnumerateObject())
+ {
+ if (pathProperty.Value.ValueKind != JsonValueKind.Object)
+ continue;
+
+ foreach (var verb in HttpVerbs)
+ {
+ if (pathProperty.Value.TryGetProperty(verb, out var operation)
+ && operation.ValueKind == JsonValueKind.Object)
+ {
+ yield return (pathProperty.Name, pathProperty.Value, verb, operation);
+ }
+ }
+ }
+ }
+
+ private static string ResolveKind(JsonElement pathItem, JsonElement operation)
+ {
+ if (!operation.TryGetProperty("x-ms-trigger", out _))
+ return "action";
+
+ // Webhook triggers register a callback URL instead of polling.
+ return pathItem.TryGetProperty("x-ms-notification-content", out _)
+ || operation.TryGetProperty("x-ms-notification-content", out _)
+ ? "webhook-trigger"
+ : "trigger";
+ }
+
+ private static IReadOnlyList CollectParameters(
+ JsonElement swagger,
+ JsonElement pathItem,
+ JsonElement operation)
+ {
+ var parameters = new List();
+
+ foreach (var parameter in EnumerateDeclaredParameters(swagger, pathItem, operation))
+ {
+ if (IsInternal(parameter))
+ continue;
+
+ var location = TryReadOptionalString(parameter, "in") ?? "query";
+ if (!string.Equals(location, "body", StringComparison.OrdinalIgnoreCase))
+ {
+ parameters.Add(new ConnectorOperationParameter(
+ Name: TryReadOptionalString(parameter, "name") ?? string.Empty,
+ In: location,
+ Type: ReadTypeLabel(parameter),
+ Required: ReadRequiredFlag(parameter),
+ Summary: TryReadOptionalString(parameter, "x-ms-summary"),
+ Description: TryReadOptionalString(parameter, "description"),
+ EnumValues: ReadEnumValues(parameter),
+ IsDynamic: HasDynamicExtension(parameter)));
+ continue;
+ }
+
+ FlattenBodyParameter(swagger, parameter, parameters);
+ }
+
+ return parameters;
+ }
+
+ private static IEnumerable EnumerateDeclaredParameters(
+ JsonElement swagger,
+ JsonElement pathItem,
+ JsonElement operation)
+ {
+ // Swagger 2.0: an operation-level parameter overrides a pathItem-level
+ // one with the same (name, in) pair.
+ var merged = new List();
+ var indexByKey = new Dictionary<(string Name, string In), int>();
+
+ foreach (var owner in new[] { pathItem, operation })
+ {
+ if (!owner.TryGetProperty("parameters", out var declared) || declared.ValueKind != JsonValueKind.Array)
+ continue;
+
+ foreach (var parameter in declared.EnumerateArray())
+ {
+ var resolved = ResolveRef(swagger, parameter);
+ if (resolved.ValueKind != JsonValueKind.Object)
+ continue;
+
+ var key = (
+ TryReadOptionalString(resolved, "name") ?? string.Empty,
+ TryReadOptionalString(resolved, "in") ?? "query");
+ if (indexByKey.TryGetValue(key, out var existing))
+ {
+ merged[existing] = resolved;
+ }
+ else
+ {
+ indexByKey[key] = merged.Count;
+ merged.Add(resolved);
+ }
+ }
+ }
+
+ return merged;
+ }
+
+ private static void FlattenBodyParameter(
+ JsonElement swagger,
+ JsonElement parameter,
+ List parameters)
+ {
+ var name = TryReadOptionalString(parameter, "name") ?? "body";
+ var required = ReadRequiredFlag(parameter);
+
+ if (!parameter.TryGetProperty("schema", out var schema))
+ return;
+
+ var resolved = ResolveRef(swagger, schema);
+
+ // Dynamic schemas depend on another parameter's value (e.g. the picked
+ // entity); surface a single marker parameter instead of empty output.
+ if (HasDynamicSchema(resolved))
+ {
+ parameters.Add(new ConnectorOperationParameter(
+ name, "body", "dynamic", required,
+ TryReadOptionalString(parameter, "x-ms-summary"),
+ TryReadOptionalString(parameter, "description"),
+ EnumValues: null,
+ IsDynamic: true));
+ return;
+ }
+
+ // Real flow definitions key body values as {bodyParamName}/{leaf}
+ // (e.g. item/subject, emailMessage/To), so the flatten prefix starts
+ // with the body parameter name.
+ var countBeforeFlatten = parameters.Count;
+ FlattenSchema(swagger, resolved, prefix: name, required, parameters, depth: 0);
+
+ if (parameters.Count == countBeforeFlatten)
+ {
+ parameters.Add(new ConnectorOperationParameter(
+ name, "body", ReadTypeLabel(resolved), required,
+ TryReadOptionalString(parameter, "x-ms-summary"),
+ TryReadOptionalString(parameter, "description"),
+ ReadEnumValues(resolved),
+ HasDynamicExtension(resolved)));
+ }
+ }
+
+ private static void FlattenSchema(
+ JsonElement swagger,
+ JsonElement schema,
+ string? prefix,
+ bool ancestorsRequired,
+ List parameters,
+ int depth)
+ {
+ if (depth > MaxFlattenDepth)
+ return;
+
+ if (schema.TryGetProperty("allOf", out var allOf) && allOf.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var branch in allOf.EnumerateArray())
+ {
+ var resolvedBranch = ResolveRef(swagger, branch);
+ if (resolvedBranch.ValueKind == JsonValueKind.Object)
+ FlattenSchema(swagger, resolvedBranch, prefix, ancestorsRequired, parameters, depth + 1);
+ }
+ }
+
+ if (!schema.TryGetProperty("properties", out var properties) || properties.ValueKind != JsonValueKind.Object)
+ return;
+
+ var requiredNames = ReadRequiredNames(schema);
+
+ foreach (var property in properties.EnumerateObject())
+ {
+ var propertySchema = ResolveRef(swagger, property.Value);
+ if (propertySchema.ValueKind != JsonValueKind.Object || IsInternal(propertySchema) || IsReadOnly(propertySchema))
+ continue;
+
+ var name = prefix is null ? property.Name : $"{prefix}/{property.Name}";
+ var required = ancestorsRequired && requiredNames.Contains(property.Name);
+ var isDynamicSchema = HasDynamicSchema(propertySchema);
+
+ if (!isDynamicSchema && IsFlattenable(propertySchema) && depth < MaxFlattenDepth)
+ {
+ FlattenSchema(swagger, propertySchema, name, required, parameters, depth + 1);
+ continue;
+ }
+
+ var truncated = !isDynamicSchema && IsFlattenable(propertySchema);
+ parameters.Add(new ConnectorOperationParameter(
+ Name: name,
+ In: "body",
+ Type: isDynamicSchema ? "dynamic" : truncated ? "object (truncated)" : ReadTypeLabel(propertySchema),
+ Required: required,
+ Summary: TryReadOptionalString(propertySchema, "x-ms-summary"),
+ Description: TryReadOptionalString(propertySchema, "description"),
+ EnumValues: ReadEnumValues(propertySchema),
+ IsDynamic: isDynamicSchema || HasDynamicExtension(propertySchema)));
+ }
+ }
+
+ private static JsonElement ResolveRef(JsonElement swagger, JsonElement element)
+ {
+ var visited = new HashSet(StringComparer.Ordinal);
+
+ while (element.ValueKind == JsonValueKind.Object
+ && element.TryGetProperty("$ref", out var refElement)
+ && refElement.ValueKind == JsonValueKind.String)
+ {
+ var reference = refElement.GetString();
+ if (string.IsNullOrWhiteSpace(reference) || !reference.StartsWith("#/", StringComparison.Ordinal) || !visited.Add(reference))
+ return element;
+
+ var current = swagger;
+ foreach (var segment in reference[2..].Split('/'))
+ {
+ if (current.ValueKind != JsonValueKind.Object || !current.TryGetProperty(segment, out current))
+ return element;
+ }
+
+ element = current;
+ }
+
+ return element;
+ }
+
+ private static bool IsFlattenable(JsonElement schema)
+ => schema.ValueKind == JsonValueKind.Object
+ && ((schema.TryGetProperty("properties", out var properties) && properties.ValueKind == JsonValueKind.Object)
+ || (schema.TryGetProperty("allOf", out var allOf) && allOf.ValueKind == JsonValueKind.Array));
+
+ private static bool IsInternal(JsonElement element)
+ => string.Equals(TryReadOptionalString(element, "x-ms-visibility"), "internal", StringComparison.OrdinalIgnoreCase);
+
+ private static bool IsReadOnly(JsonElement element)
+ => element.TryGetProperty("readOnly", out var readOnly) && readOnly.ValueKind == JsonValueKind.True;
+
+ private static bool ReadRequiredFlag(JsonElement parameter)
+ => parameter.TryGetProperty("required", out var required) && required.ValueKind == JsonValueKind.True;
+
+ private static HashSet ReadRequiredNames(JsonElement schema)
+ {
+ var names = new HashSet(StringComparer.Ordinal);
+ if (schema.TryGetProperty("required", out var required) && required.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var name in required.EnumerateArray())
+ {
+ if (name.ValueKind == JsonValueKind.String)
+ names.Add(name.GetString()!);
+ }
+ }
+
+ return names;
+ }
+
+ private static string? ReadTypeLabel(JsonElement element)
+ {
+ var type = TryReadOptionalString(element, "type");
+ var format = TryReadOptionalString(element, "format");
+ return type is null ? null : format is null ? type : $"{type} ({format})";
+ }
+
+ private static IReadOnlyList? ReadEnumValues(JsonElement element)
+ {
+ if (!element.TryGetProperty("enum", out var enumElement) || enumElement.ValueKind != JsonValueKind.Array)
+ return null;
+
+ var values = enumElement.EnumerateArray()
+ .Select(v => v.ValueKind == JsonValueKind.String ? v.GetString()! : v.GetRawText())
+ .ToList();
+ return values.Count > 0 ? values : null;
+ }
+
+ private static bool HasDynamicExtension(JsonElement element)
+ => element.ValueKind == JsonValueKind.Object
+ && (element.TryGetProperty("x-ms-dynamic-values", out _)
+ || element.TryGetProperty("x-ms-dynamic-list", out _)
+ || element.TryGetProperty("x-ms-dynamic-tree", out _));
+
+ private static bool HasDynamicSchema(JsonElement element)
+ => element.ValueKind == JsonValueKind.Object
+ && (element.TryGetProperty("x-ms-dynamic-schema", out _)
+ || element.TryGetProperty("x-ms-dynamic-properties", out _));
+
+ private static bool TryReadString(JsonElement element, string property, out string value)
+ {
+ value = string.Empty;
+ if (!element.TryGetProperty(property, out var propertyElement) || propertyElement.ValueKind != JsonValueKind.String)
+ return false;
+
+ var raw = propertyElement.GetString();
+ if (string.IsNullOrWhiteSpace(raw))
+ return false;
+
+ value = raw.Trim();
+ return true;
+ }
+
+ private static string? TryReadOptionalString(JsonElement element, string property)
+ => element.ValueKind == JsonValueKind.Object
+ && element.TryGetProperty(property, out var propertyElement)
+ && propertyElement.ValueKind == JsonValueKind.String
+ ? propertyElement.GetString()?.Trim()
+ : null;
+}
diff --git a/src/TALXIS.CLI.Platform.PowerPlatform.Control/PowerApps/PowerAppsEndpointProvider.cs b/src/TALXIS.CLI.Platform.PowerPlatform.Control/PowerApps/PowerAppsEndpointProvider.cs
new file mode 100644
index 00000000..7ba2239e
--- /dev/null
+++ b/src/TALXIS.CLI.Platform.PowerPlatform.Control/PowerApps/PowerAppsEndpointProvider.cs
@@ -0,0 +1,29 @@
+using TALXIS.CLI.Core.Model;
+using TALXIS.CLI.Platform.PowerPlatform.Control.Bap;
+
+namespace TALXIS.CLI.Platform.PowerPlatform.Control.PowerApps;
+
+///
+/// Endpoint constants for the Power Apps connector API (api.powerapps.com).
+/// Kept intentionally explicit so an unmapped cloud fails loudly rather than
+/// silently targeting the wrong host — the sovereign-cloud hosts differ from
+/// the BAP admin API pattern (GCC does not share the commercial host here).
+///
+internal static class PowerAppsEndpointProvider
+{
+ ///
+ /// The connector API validates the same audience as the BAP admin API,
+ /// so one cached token serves both.
+ ///
+ public static readonly Uri Audience = BapEndpointProvider.PowerAppsAudience;
+
+ public const string ApiVersion = "2016-11-01";
+
+ public static Uri GetApiBaseUri(CloudInstance cloud)
+ => cloud switch
+ {
+ CloudInstance.Public => new Uri("https://api.powerapps.com/"),
+ _ => throw new NotSupportedException(
+ $"The Power Apps connector API is not wired for cloud '{cloud}' in this release."),
+ };
+}
diff --git a/tests/TALXIS.CLI.Tests/Config/Providers/PowerPlatform/ConnectorCatalogServiceTests.cs b/tests/TALXIS.CLI.Tests/Config/Providers/PowerPlatform/ConnectorCatalogServiceTests.cs
new file mode 100644
index 00000000..e71595da
--- /dev/null
+++ b/tests/TALXIS.CLI.Tests/Config/Providers/PowerPlatform/ConnectorCatalogServiceTests.cs
@@ -0,0 +1,269 @@
+using System.Net;
+using System.Net.Http;
+using TALXIS.CLI.Core.Abstractions;
+using TALXIS.CLI.Core.Model;
+using TALXIS.CLI.Platform.PowerPlatform.Control;
+using Xunit;
+
+namespace TALXIS.CLI.Tests.Config.Providers.PowerPlatform;
+
+public sealed class ConnectorCatalogServiceTests
+{
+ private static readonly Guid EnvironmentId = Guid.Parse("11111111-2222-3333-4444-555555555555");
+
+ [Fact]
+ public async Task ListConnectorsAsync_UsesPowerAppsAudience_AndEnvironmentFilter()
+ {
+ var tokens = new FakeAccessTokenService();
+ Uri? requestedUri = null;
+ var http = new FakeHttpClientFactoryWrapper(request =>
+ {
+ requestedUri = request.RequestUri;
+ return Json("""{"value":[{"name":"shared_teams","properties":{"displayName":"Microsoft Teams","tier":"Standard"}}]}""");
+ });
+
+ var sut = CreateService(tokens, http);
+ var connectors = await sut.ListConnectorsAsync(null, CancellationToken.None);
+
+ Assert.Equal(new Uri("https://service.powerapps.com/"), tokens.LastResourceUri);
+ Assert.NotNull(requestedUri);
+ Assert.StartsWith("https://api.powerapps.com/providers/Microsoft.PowerApps/apis", requestedUri!.AbsoluteUri);
+ Assert.Contains("api-version=2016-11-01", requestedUri.Query);
+ Assert.Contains(Uri.EscapeDataString($"environment eq '{EnvironmentId:D}'"), requestedUri.Query);
+
+ var connector = Assert.Single(connectors);
+ Assert.Equal("shared_teams", connector.Name);
+ Assert.Equal("Microsoft Teams", connector.DisplayName);
+ Assert.Equal("Standard", connector.Tier);
+ Assert.False(connector.IsCustomApi);
+ }
+
+ [Fact]
+ public async Task ListConnectorsAsync_FollowsNextLink_AndSortsByName()
+ {
+ var page1 = """{"value":[{"name":"shared_zendesk","properties":{"displayName":"Zendesk"}}],"nextLink":"https://api.powerapps.com/providers/Microsoft.PowerApps/apis?page=2"}""";
+ var page2 = """{"value":[{"name":"shared_approvals","properties":{"displayName":"Approvals","isCustomApi":true}}]}""";
+
+ var calls = 0;
+ var http = new FakeHttpClientFactoryWrapper(_ => Json(++calls == 1 ? page1 : page2));
+
+ var sut = CreateService(new FakeAccessTokenService(), http);
+ var connectors = await sut.ListConnectorsAsync(null, CancellationToken.None);
+
+ Assert.Equal(2, calls);
+ Assert.Equal(["shared_approvals", "shared_zendesk"], connectors.Select(c => c.Name).ToList());
+ Assert.True(connectors[0].IsCustomApi);
+ }
+
+ [Fact]
+ public async Task GetOperationAsync_ParsesInlineSwagger_AndBuildsApiId()
+ {
+ Uri? requestedUri = null;
+ var http = new FakeHttpClientFactoryWrapper(request =>
+ {
+ requestedUri = request.RequestUri;
+ return Json("""
+ {
+ "name": "shared_test",
+ "properties": {
+ "swagger": {
+ "paths": {
+ "/items": {
+ "post": {
+ "operationId": "CreateItem",
+ "parameters": [ { "name": "folder", "in": "query", "type": "string", "required": true } ]
+ }
+ }
+ }
+ }
+ }
+ }
+ """);
+ });
+
+ var sut = CreateService(new FakeAccessTokenService(), http);
+ var detail = await sut.GetOperationAsync(null, "shared_test", "CreateItem", CancellationToken.None);
+
+ Assert.NotNull(detail);
+ Assert.Contains("$expand=properties.swagger", requestedUri!.Query, StringComparison.OrdinalIgnoreCase);
+ Assert.Equal("/providers/Microsoft.PowerApps/apis/shared_test", detail.ApiId);
+ Assert.Equal("CreateItem", detail.OperationId);
+ var parameter = Assert.Single(detail.Parameters);
+ Assert.Equal("folder", parameter.Name);
+ }
+
+ [Fact]
+ public async Task GetOperationAsync_FallsBackToApiDefinitionsBlob_WithoutAuthorizationHeader()
+ {
+ var blobRequests = new List();
+ var http = new FakeHttpClientFactoryWrapper(request =>
+ {
+ if (request.RequestUri!.Host == "blob.example.com")
+ {
+ blobRequests.Add(request);
+ return Json("""{"paths":{"/x":{"get":{"operationId":"GetX"}}}}""");
+ }
+
+ return Json("""{"name":"shared_test","properties":{"apiDefinitions":{"originalSwaggerUrl":"https://blob.example.com/swagger.json?sig=abc"}}}""");
+ });
+
+ var sut = CreateService(new FakeAccessTokenService(), http);
+ var operations = await sut.ListOperationsAsync(null, "shared_test", CancellationToken.None);
+
+ var operation = Assert.Single(operations);
+ Assert.Equal("GetX", operation.OperationId);
+ var blobRequest = Assert.Single(blobRequests);
+ Assert.Null(blobRequest.Headers.Authorization);
+ }
+
+ [Fact]
+ public async Task GetSwagger_Throws_WhenConnectorHasNoDefinition()
+ {
+ var http = new FakeHttpClientFactoryWrapper(_ => Json("""{"name":"shared_test","properties":{}}"""));
+ var sut = CreateService(new FakeAccessTokenService(), http);
+
+ var ex = await Assert.ThrowsAsync(
+ () => sut.ListOperationsAsync(null, "shared_test", CancellationToken.None));
+ Assert.Contains("no OpenAPI definition", ex.Message);
+ }
+
+ [Fact]
+ public async Task GetSwagger_ThrowsFriendlyError_WhenPropertiesIsNull()
+ {
+ var http = new FakeHttpClientFactoryWrapper(_ => Json("""{"name":"shared_test","properties":null}"""));
+ var sut = CreateService(new FakeAccessTokenService(), http);
+
+ var ex = await Assert.ThrowsAsync(
+ () => sut.ListOperationsAsync(null, "shared_test", CancellationToken.None));
+ Assert.Contains("no OpenAPI definition", ex.Message);
+ }
+
+ [Fact]
+ public async Task GetSwagger_ThrowsNotFoundHint_On404()
+ {
+ var http = new FakeHttpClientFactoryWrapper(_ => new HttpResponseMessage(HttpStatusCode.NotFound)
+ {
+ Content = new StringContent("{}"),
+ });
+ var sut = CreateService(new FakeAccessTokenService(), http);
+
+ var ex = await Assert.ThrowsAsync(
+ () => sut.ListOperationsAsync(null, "shared_missing", CancellationToken.None));
+ Assert.Contains("was not found in the environment", ex.Message);
+ Assert.Contains("connector list", ex.Message);
+ }
+
+ [Fact]
+ public async Task ListConnectorsAsync_ResolvesRelativeNextLink_AgainstBaseUri()
+ {
+ var requests = new List();
+ var http = new FakeHttpClientFactoryWrapper(request =>
+ {
+ requests.Add(request.RequestUri!);
+ return Json(requests.Count == 1
+ ? """{"value":[],"nextLink":"providers/Microsoft.PowerApps/apis?page=2"}"""
+ : """{"value":[]}""");
+ });
+
+ var sut = CreateService(new FakeAccessTokenService(), http);
+ await sut.ListConnectorsAsync(null, CancellationToken.None);
+
+ Assert.Equal(2, requests.Count);
+ Assert.Equal("https://api.powerapps.com/providers/Microsoft.PowerApps/apis?page=2", requests[1].AbsoluteUri);
+ }
+
+ [Fact]
+ public async Task ListConnectorsAsync_Throws_WithTruncatedBody_OnHttpFailure()
+ {
+ var http = new FakeHttpClientFactoryWrapper(_ => new HttpResponseMessage(HttpStatusCode.Forbidden)
+ {
+ Content = new StringContent("denied"),
+ });
+
+ var sut = CreateService(new FakeAccessTokenService(), http);
+
+ var ex = await Assert.ThrowsAsync(
+ () => sut.ListConnectorsAsync(null, CancellationToken.None));
+ Assert.Contains("403", ex.Message);
+ Assert.Contains("denied", ex.Message);
+ }
+
+ private static ConnectorCatalogService CreateService(FakeAccessTokenService tokens, FakeHttpClientFactoryWrapper http)
+ => new(
+ new FakeResolver(new ResolvedProfileContext(
+ Profile: null,
+ Connection: new Connection
+ {
+ Id = "conn",
+ Provider = ProviderKind.Dataverse,
+ EnvironmentUrl = "https://contoso.crm.dynamics.com/",
+ Cloud = CloudInstance.Public,
+ EnvironmentId = EnvironmentId,
+ },
+ Credential: new Credential { Id = "cred", Kind = CredentialKind.InteractiveBrowser },
+ Source: ResolutionSource.Global)),
+ new ThrowingCatalog(),
+ tokens,
+ http);
+
+ private static HttpResponseMessage Json(string body)
+ => new(HttpStatusCode.OK) { Content = new StringContent(body) };
+
+ private sealed class FakeResolver : IConfigurationResolver
+ {
+ private readonly ResolvedProfileContext _ctx;
+
+ public FakeResolver(ResolvedProfileContext ctx) => _ctx = ctx;
+
+ public Task ResolveAsync(string? profileName, CancellationToken ct)
+ => Task.FromResult(_ctx);
+ }
+
+ // EnvironmentId is stored on the connection, so the catalog must never be hit.
+ private sealed class ThrowingCatalog : IPowerPlatformEnvironmentCatalog
+ {
+ public Task> ListAsync(
+ Connection connection, Credential credential, CancellationToken ct)
+ => throw new InvalidOperationException("Catalog should not be called.");
+
+ public Task TryGetByEnvironmentUrlAsync(
+ Connection connection, Credential credential, Uri environmentUrl, CancellationToken ct)
+ => throw new InvalidOperationException("Catalog should not be called.");
+ }
+
+ private sealed class FakeAccessTokenService : IAccessTokenService
+ {
+ public Uri? LastResourceUri { get; private set; }
+
+ public Task AcquireForResourceAsync(Connection connection, Credential credential, Uri resourceUri, CancellationToken ct)
+ {
+ LastResourceUri = resourceUri;
+ return Task.FromResult("token");
+ }
+ }
+
+ private sealed class FakeHttpClientFactoryWrapper : IHttpClientFactoryWrapper
+ {
+ private readonly Func _handler;
+
+ public FakeHttpClientFactoryWrapper(Func handler)
+ {
+ _handler = handler;
+ }
+
+ public HttpClient Create() => new(new FakeHttpMessageHandler(_handler));
+ }
+
+ private sealed class FakeHttpMessageHandler : HttpMessageHandler
+ {
+ private readonly Func _handler;
+
+ public FakeHttpMessageHandler(Func handler)
+ {
+ _handler = handler;
+ }
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ => Task.FromResult(_handler(request));
+ }
+}
diff --git a/tests/TALXIS.CLI.Tests/Config/Providers/PowerPlatform/ConnectorSwaggerParserTests.cs b/tests/TALXIS.CLI.Tests/Config/Providers/PowerPlatform/ConnectorSwaggerParserTests.cs
new file mode 100644
index 00000000..bfbe7ce3
--- /dev/null
+++ b/tests/TALXIS.CLI.Tests/Config/Providers/PowerPlatform/ConnectorSwaggerParserTests.cs
@@ -0,0 +1,330 @@
+using System.Text.Json;
+using TALXIS.CLI.Platform.PowerPlatform.Control.PowerApps;
+using Xunit;
+
+namespace TALXIS.CLI.Tests.Config.Providers.PowerPlatform;
+
+public sealed class ConnectorSwaggerParserTests
+{
+ private const string Fixture = """
+ {
+ "swagger": "2.0",
+ "paths": {
+ "/items": {
+ "post": {
+ "operationId": "CreateItem",
+ "summary": "Create an item",
+ "parameters": [
+ { "name": "folder", "in": "query", "type": "string", "required": true, "x-ms-summary": "Folder", "x-ms-dynamic-values": { "operationId": "ListFolders" } },
+ { "name": "secret", "in": "header", "type": "string", "x-ms-visibility": "internal" },
+ { "name": "item", "in": "body", "required": true, "schema": { "$ref": "#/definitions/Item" } }
+ ]
+ }
+ },
+ "/trigger-poll": {
+ "get": { "operationId": "WhenItemChanges", "summary": "Poll trigger", "x-ms-trigger": "batch" }
+ },
+ "/trigger-hook": {
+ "x-ms-notification-content": { "schema": { "type": "object" } },
+ "post": { "operationId": "WhenHookFires", "summary": "Webhook trigger", "x-ms-trigger": "single", "x-ms-visibility": "important" }
+ }
+ },
+ "definitions": {
+ "Item": {
+ "type": "object",
+ "required": ["subject"],
+ "properties": {
+ "subject": { "type": "string", "x-ms-summary": "Subject" },
+ "priority": { "type": "integer", "format": "int32", "enum": [1, 2] },
+ "status": { "type": "string", "enum": ["Active", "Closed"] },
+ "internalOnly": { "type": "string", "x-ms-visibility": "internal" },
+ "createdOn": { "type": "string", "readOnly": true },
+ "details": {
+ "type": "object",
+ "required": ["note"],
+ "properties": { "note": { "type": "string" } }
+ }
+ }
+ }
+ }
+ }
+ """;
+
+ private static JsonElement ParseFixture()
+ {
+ using var document = JsonDocument.Parse(Fixture);
+ return document.RootElement.Clone();
+ }
+
+ [Fact]
+ public void ListOperations_ClassifiesActionTriggerAndWebhookTrigger()
+ {
+ var operations = ConnectorSwaggerParser.ListOperations(ParseFixture());
+
+ Assert.Equal(3, operations.Count);
+ Assert.Equal("action", operations.Single(o => o.OperationId == "CreateItem").Kind);
+ Assert.Equal("trigger", operations.Single(o => o.OperationId == "WhenItemChanges").Kind);
+ Assert.Equal("webhook-trigger", operations.Single(o => o.OperationId == "WhenHookFires").Kind);
+ Assert.Equal("important", operations.Single(o => o.OperationId == "WhenHookFires").Visibility);
+ }
+
+ [Fact]
+ public void GetOperation_FlattensBodySchemaIntoSlashJoinedLeaves()
+ {
+ var detail = ConnectorSwaggerParser.GetOperation(
+ ParseFixture(), "shared_test", "/providers/Microsoft.PowerApps/apis/shared_test", "CreateItem");
+
+ Assert.NotNull(detail);
+ Assert.Equal("POST", detail.HttpMethod);
+ Assert.Equal("/items", detail.Path);
+ Assert.Equal("/providers/Microsoft.PowerApps/apis/shared_test", detail.ApiId);
+
+ var names = detail.Parameters.Select(p => p.Name).ToList();
+ Assert.Contains("folder", names);
+ Assert.Contains("item/subject", names);
+ Assert.Contains("item/priority", names);
+ Assert.Contains("item/status", names);
+ Assert.Contains("item/details/note", names);
+ Assert.DoesNotContain("secret", names);
+ Assert.DoesNotContain("item/internalOnly", names);
+ Assert.DoesNotContain("item/createdOn", names);
+ }
+
+ [Fact]
+ public void GetOperation_PropagatesRequiredAndDynamicAndEnums()
+ {
+ var detail = ConnectorSwaggerParser.GetOperation(
+ ParseFixture(), "shared_test", "/api", "CreateItem")!;
+
+ var folder = detail.Parameters.Single(p => p.Name == "folder");
+ Assert.True(folder.Required);
+ Assert.True(folder.IsDynamic);
+ Assert.Equal("query", folder.In);
+
+ var subject = detail.Parameters.Single(p => p.Name == "item/subject");
+ Assert.True(subject.Required);
+ Assert.Equal("body", subject.In);
+
+ // details is not listed in Item.required, so its children are optional.
+ var note = detail.Parameters.Single(p => p.Name == "item/details/note");
+ Assert.False(note.Required);
+
+ var priority = detail.Parameters.Single(p => p.Name == "item/priority");
+ Assert.Equal("integer (int32)", priority.Type);
+ Assert.Equal(["1", "2"], priority.EnumValues);
+
+ var status = detail.Parameters.Single(p => p.Name == "item/status");
+ Assert.Equal(["Active", "Closed"], status.EnumValues);
+ }
+
+ [Fact]
+ public void GetOperation_ReturnsNull_ForUnknownOperation()
+ {
+ var detail = ConnectorSwaggerParser.GetOperation(ParseFixture(), "shared_test", "/api", "DoesNotExist");
+ Assert.Null(detail);
+ }
+
+ [Fact]
+ public void GetOperation_MatchesOperationIdCaseInsensitively()
+ {
+ var detail = ConnectorSwaggerParser.GetOperation(ParseFixture(), "shared_test", "/api", "createitem");
+ Assert.NotNull(detail);
+ Assert.Equal("CreateItem", detail.OperationId);
+ }
+
+ [Fact]
+ public void GetOperation_DeduplicatesPathItemParameters_OperationLevelWins()
+ {
+ const string fixture = """
+ {
+ "swagger": "2.0",
+ "paths": {
+ "/items/{id}": {
+ "parameters": [
+ { "name": "id", "in": "path", "type": "string", "required": true, "description": "path-level" }
+ ],
+ "get": {
+ "operationId": "GetItem",
+ "parameters": [
+ { "name": "id", "in": "path", "type": "string", "required": true, "description": "operation-level" }
+ ]
+ }
+ }
+ }
+ }
+ """;
+
+ using var document = JsonDocument.Parse(fixture);
+ var detail = ConnectorSwaggerParser.GetOperation(document.RootElement, "shared_test", "/api", "GetItem")!;
+
+ var id = Assert.Single(detail.Parameters);
+ Assert.Equal("operation-level", id.Description);
+ }
+
+ [Fact]
+ public void GetOperation_EmitsBodyMarker_WhenFlattenYieldsNothing_EvenWithOtherParameters()
+ {
+ const string fixture = """
+ {
+ "swagger": "2.0",
+ "paths": {
+ "/items": {
+ "post": {
+ "operationId": "CreateItem",
+ "parameters": [
+ { "name": "q", "in": "query", "type": "string" },
+ { "name": "item", "in": "body", "required": true, "schema": { "type": "object", "properties": { "hidden": { "type": "string", "x-ms-visibility": "internal" } } } }
+ ]
+ }
+ }
+ }
+ }
+ """;
+
+ using var document = JsonDocument.Parse(fixture);
+ var detail = ConnectorSwaggerParser.GetOperation(document.RootElement, "shared_test", "/api", "CreateItem")!;
+
+ Assert.Contains(detail.Parameters, p => p.Name == "q");
+ Assert.Contains(detail.Parameters, p => p.Name == "item" && p.In == "body");
+ }
+
+ [Fact]
+ public void GetOperation_MarksNestedDynamicSchemaProperty_AsDynamic()
+ {
+ const string fixture = """
+ {
+ "swagger": "2.0",
+ "paths": {
+ "/rows": {
+ "post": {
+ "operationId": "CreateRow",
+ "parameters": [
+ { "name": "item", "in": "body", "required": true, "schema": { "type": "object", "properties": { "row": { "type": "object", "x-ms-dynamic-schema": { "operationId": "GetSchema" } } } } }
+ ]
+ }
+ }
+ }
+ }
+ """;
+
+ using var document = JsonDocument.Parse(fixture);
+ var detail = ConnectorSwaggerParser.GetOperation(document.RootElement, "shared_test", "/api", "CreateRow")!;
+
+ var row = detail.Parameters.Single(p => p.Name == "item/row");
+ Assert.Equal("dynamic", row.Type);
+ Assert.True(row.IsDynamic);
+ }
+
+ [Fact]
+ public void GetOperation_MergesAllOfBranches()
+ {
+ const string fixture = """
+ {
+ "swagger": "2.0",
+ "paths": {
+ "/items": {
+ "post": {
+ "operationId": "CreateItem",
+ "parameters": [
+ { "name": "item", "in": "body", "required": true, "schema": { "$ref": "#/definitions/Derived" } }
+ ]
+ }
+ }
+ },
+ "definitions": {
+ "Base": { "type": "object", "properties": { "id": { "type": "string" } } },
+ "Derived": { "allOf": [ { "$ref": "#/definitions/Base" } ], "properties": { "name": { "type": "string" } } }
+ }
+ }
+ """;
+
+ using var document = JsonDocument.Parse(fixture);
+ var detail = ConnectorSwaggerParser.GetOperation(document.RootElement, "shared_test", "/api", "CreateItem")!;
+
+ var names = detail.Parameters.Select(p => p.Name).ToList();
+ Assert.Contains("item/id", names);
+ Assert.Contains("item/name", names);
+ }
+
+ [Fact]
+ public void GetOperation_MarksDepthCappedObjects_AsTruncated()
+ {
+ const string fixture = """
+ {
+ "swagger": "2.0",
+ "paths": {
+ "/deep": {
+ "post": {
+ "operationId": "Deep",
+ "parameters": [
+ { "name": "item", "in": "body", "schema": { "type": "object", "properties": { "l1": { "type": "object", "properties": { "l2": { "type": "object", "properties": { "l3": { "type": "object", "properties": { "l4": { "type": "object", "properties": { "l5": { "type": "object", "properties": { "leaf": { "type": "string" } } } } } } } } } } } } } }
+ ]
+ }
+ }
+ }
+ }
+ """;
+
+ using var document = JsonDocument.Parse(fixture);
+ var detail = ConnectorSwaggerParser.GetOperation(document.RootElement, "shared_test", "/api", "Deep")!;
+
+ var capped = detail.Parameters.Single();
+ Assert.Equal("object (truncated)", capped.Type);
+ }
+
+ [Fact]
+ public void GetOperation_TreatsDynamicProperties_AsDynamicSchema()
+ {
+ const string fixture = """
+ {
+ "swagger": "2.0",
+ "paths": {
+ "/rows": {
+ "post": {
+ "operationId": "CreateRow",
+ "parameters": [
+ { "name": "item", "in": "body", "required": true, "schema": { "type": "object", "x-ms-dynamic-properties": { "operationId": "GetSchema" } } }
+ ]
+ }
+ }
+ }
+ }
+ """;
+
+ using var document = JsonDocument.Parse(fixture);
+ var detail = ConnectorSwaggerParser.GetOperation(document.RootElement, "shared_test", "/api", "CreateRow")!;
+
+ var parameter = Assert.Single(detail.Parameters);
+ Assert.Equal("dynamic", parameter.Type);
+ Assert.True(parameter.IsDynamic);
+ }
+
+ [Fact]
+ public void GetOperation_EmitsSingleDynamicParameter_ForDynamicSchema()
+ {
+ const string dynamicFixture = """
+ {
+ "swagger": "2.0",
+ "paths": {
+ "/rows": {
+ "post": {
+ "operationId": "CreateRow",
+ "parameters": [
+ { "name": "item", "in": "body", "required": true, "schema": { "type": "object", "x-ms-dynamic-schema": { "operationId": "GetSchema" } } }
+ ]
+ }
+ }
+ }
+ }
+ """;
+
+ using var document = JsonDocument.Parse(dynamicFixture);
+ var detail = ConnectorSwaggerParser.GetOperation(document.RootElement, "shared_test", "/api", "CreateRow")!;
+
+ var parameter = Assert.Single(detail.Parameters);
+ Assert.Equal("item", parameter.Name);
+ Assert.Equal("dynamic", parameter.Type);
+ Assert.True(parameter.IsDynamic);
+ Assert.True(parameter.Required);
+ }
+}