diff --git a/.claude/skills/maui-ai-debugging/references/linux.md b/.claude/skills/maui-ai-debugging/references/linux.md
index 343b4323b..81fe97b43 100644
--- a/.claude/skills/maui-ai-debugging/references/linux.md
+++ b/.claude/skills/maui-ai-debugging/references/linux.md
@@ -31,8 +31,8 @@ standard MAUI platforms (iOS, Android, macCatalyst, Windows).
### MauiProgram.cs
```csharp
-using MauiDevFlow.Agent.Gtk;
-using MauiDevFlow.Blazor.Gtk; // Blazor Hybrid only
+using Microsoft.Maui.DevFlow.Agent.Gtk;
+using Microsoft.Maui.DevFlow.Blazor.Gtk; // Blazor Hybrid only
var builder = MauiApp.CreateBuilder();
// ... your existing setup ...
diff --git a/.claude/skills/maui-ai-debugging/references/macos.md b/.claude/skills/maui-ai-debugging/references/macos.md
index e4db4a21a..e78f7552f 100644
--- a/.claude/skills/maui-ai-debugging/references/macos.md
+++ b/.claude/skills/maui-ai-debugging/references/macos.md
@@ -92,8 +92,8 @@ using Microsoft.Maui.Platform.MacOS;
using Microsoft.Maui.Platform.MacOS.Controls;
#if DEBUG
-using MauiDevFlow.Agent;
-using MauiDevFlow.Blazor;
+using Microsoft.Maui.DevFlow.Agent;
+using Microsoft.Maui.DevFlow.Blazor;
#endif
public static class MauiProgram
diff --git a/.claude/skills/maui-ai-debugging/references/setup.md b/.claude/skills/maui-ai-debugging/references/setup.md
index b8ea78d84..ae17188bc 100644
--- a/.claude/skills/maui-ai-debugging/references/setup.md
+++ b/.claude/skills/maui-ai-debugging/references/setup.md
@@ -75,8 +75,8 @@ Linux/GTK apps (using Maui.Gtk) use separate packages:
## 3. Register in MauiProgram.cs
```csharp
-using MauiDevFlow.Agent;
-using MauiDevFlow.Blazor; // Blazor Hybrid only
+using Microsoft.Maui.DevFlow.Agent;
+using Microsoft.Maui.DevFlow.Blazor; // Blazor Hybrid only
var builder = MauiApp.CreateBuilder();
// ... your existing setup ...
@@ -93,8 +93,8 @@ builder.AddMauiBlazorDevFlowTools(); // Blazor Hybrid only
For Linux/GTK apps, use the GTK-specific namespaces and add the agent startup call:
```csharp
-using MauiDevFlow.Agent.Gtk;
-using MauiDevFlow.Blazor.Gtk; // Blazor Hybrid only
+using Microsoft.Maui.DevFlow.Agent.Gtk;
+using Microsoft.Maui.DevFlow.Blazor.Gtk; // Blazor Hybrid only
var builder = MauiApp.CreateBuilder();
// ... your existing setup ...
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 51b1c2381..8f7b6a363 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -29,6 +29,7 @@
+
diff --git a/README.md b/README.md
index a77c763ec..ea3073bb7 100644
--- a/README.md
+++ b/README.md
@@ -70,6 +70,9 @@ This repository is also a marketplace for distributable agent skills for .NET MA
# Install via Copilot CLI
/plugin marketplace add dotnet/maui-labs
/plugin install dotnet-maui@dotnet-maui-labs
+
+# Then say:
+# "Set up DevFlow in this project"
```
See [plugins/](plugins/) for the full catalog and [plugins/CONTRIBUTING.md](plugins/CONTRIBUTING.md) for how to add skills.
diff --git a/eng/Versions.props b/eng/Versions.props
index 0bcae406d..d0f6cfb38 100644
--- a/eng/Versions.props
+++ b/eng/Versions.props
@@ -35,6 +35,7 @@
3.119.2
0.54.0
2.0.5
+ 4.14.0
5.3.0
1.7.1
1.1.0
diff --git a/plugins/README.md b/plugins/README.md
index b96f5c65d..af14b6597 100644
--- a/plugins/README.md
+++ b/plugins/README.md
@@ -6,7 +6,8 @@ Distributable agent skills for .NET MAUI development. Installable via the Copilo
| Plugin | Skill | Description |
|--------|-------|-------------|
-| [dotnet-maui](dotnet-maui/) | [devflow-connect](dotnet-maui/skills/devflow-connect/) | DevFlow automation — agent connectivity, visual tree inspection, screenshots, app interactions. Requires the `maui` CLI. |
+| [dotnet-maui](dotnet-maui/) | [devflow-onboard](dotnet-maui/skills/devflow-onboard/) | DevFlow onboarding — workspace setup, MAUI project selection, AI bootstrap, and report-driven follow-up. Requires the `maui` CLI. |
+| | [devflow-connect](dotnet-maui/skills/devflow-connect/) | DevFlow automation — agent connectivity, visual tree inspection, screenshots, app interactions. Requires the `maui` CLI. |
| | [android-slim-bindings](dotnet-maui/skills/android-slim-bindings/) | Create Android slim bindings using the Native Library Interop approach. |
| | [ios-slim-bindings](dotnet-maui/skills/ios-slim-bindings/) | Create iOS slim bindings using the Native Library Interop approach. |
| | [dotnet-workload-info](dotnet-maui/skills/dotnet-workload-info/) | Discover installed .NET workloads, SDK versions, and dependency requirements. |
@@ -19,6 +20,9 @@ Distributable agent skills for .NET MAUI development. Installable via the Copilo
# Install the plugin
/plugin install dotnet-maui@dotnet-maui-labs
+
+# Then say:
+# "Set up DevFlow in this project"
```
## Adding Skills
diff --git a/plugins/dotnet-maui/skills/devflow-connect/SKILL.md b/plugins/dotnet-maui/skills/devflow-connect/SKILL.md
index e050a2584..89d02c244 100644
--- a/plugins/dotnet-maui/skills/devflow-connect/SKILL.md
+++ b/plugins/dotnet-maui/skills/devflow-connect/SKILL.md
@@ -47,7 +47,7 @@ grep -r "Microsoft.Maui.DevFlow.Agent" *.csproj
The agent must be initialized in `MauiProgram.cs`:
```csharp
-builder.Services.AddMauiDevFlowAgent();
+builder.AddMauiDevFlowAgent();
```
### 2. Check Broker Status
diff --git a/plugins/dotnet-maui/skills/devflow-onboard/SKILL.md b/plugins/dotnet-maui/skills/devflow-onboard/SKILL.md
new file mode 100644
index 000000000..b79b2a3c1
--- /dev/null
+++ b/plugins/dotnet-maui/skills/devflow-onboard/SKILL.md
@@ -0,0 +1,71 @@
+---
+name: devflow-onboard
+description: >-
+ Set up .NET MAUI projects for MAUI DevFlow using the maui CLI. USE FOR:
+ first-run DevFlow onboarding, adding DevFlow packages and MauiProgram.cs
+ registration, choosing one or more MAUI projects in a workspace, reading the
+ MAUI-DEVFLOW-INIT-REPORT.md report, and continuing after partial setup. DO
+ NOT USE FOR: troubleshooting an already-integrated app that cannot connect
+ (use devflow-connect), generic build failures, or non-MAUI projects.
+---
+
+# DevFlow Onboard
+
+Use this skill to set up DevFlow in a workspace and then continue from the CLI-authored report.
+
+## When to Use
+
+- DevFlow is not yet integrated into the current MAUI workspace
+- The user has just installed the plugin and needs the next gesture
+- A workspace contains multiple MAUI apps and the user wants to choose one or more
+- The user needs to resume from a previous init run and inspect `MAUI-DEVFLOW-INIT-REPORT.md`
+
+## Workflow
+
+1. Ensure the `maui` CLI is available. If it is missing, install or update it:
+
+ ```bash
+ dotnet tool install -g Microsoft.Maui.Cli --prerelease || dotnet tool update -g Microsoft.Maui.Cli --prerelease
+ ```
+
+2. Run DevFlow onboarding from the workspace root:
+
+ ```bash
+ maui devflow init
+ ```
+
+ Useful variants:
+
+ ```bash
+ maui devflow init --project path/to/App.csproj
+ maui devflow init --all
+ maui devflow init --no-ai
+ ```
+
+3. After `init` completes, read:
+
+ ```text
+ MAUI-DEVFLOW-INIT-REPORT.md
+ ```
+
+4. Treat the report as the source of truth for:
+
+- which projects were changed
+- which steps succeeded, were skipped, or need manual follow-up
+- which AI host was selected
+- whether repo-local skills were synced
+
+5. If setup succeeded, continue with normal DevFlow verification:
+
+ ```bash
+ maui devflow diagnose
+ maui devflow wait
+ ```
+
+6. If the report says setup is complete but the app still will not connect, switch to `devflow-connect`.
+
+## Important Rules
+
+- Prefer `maui devflow init` over hand-editing project files when possible.
+- Do not treat an empty `maui devflow list` result as proof that DevFlow is not integrated.
+- If `MAUI-DEVFLOW-INIT-REPORT.md` exists, read it before guessing what the CLI did.
diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/CommandConstructionTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/CommandConstructionTests.cs
index fcf80440a..830d3b359 100644
--- a/src/Cli/Microsoft.Maui.Cli.UnitTests/CommandConstructionTests.cs
+++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/CommandConstructionTests.cs
@@ -1,10 +1,13 @@
using System.CommandLine;
+using System.Diagnostics.CodeAnalysis;
using Microsoft.Maui.Cli.Commands;
using Microsoft.Maui.Cli.DevFlow;
using Xunit;
namespace Microsoft.Maui.Cli.UnitTests;
+[RequiresUnreferencedCode("DevFlow command construction uses MSBuild-evaluation-annotated methods.")]
+[RequiresDynamicCode("DevFlow command construction uses MSBuild-evaluation-annotated methods.")]
public class CommandConstructionTests
{
[Fact]
@@ -39,6 +42,15 @@ public void DevFlowCommand_UsesMcpAsPrimaryCommandName()
Assert.Contains("mcp-serve", mcpCommand.Aliases);
}
+ [Fact]
+ public void DevFlowCommand_ContainsInitCommand()
+ {
+ var jsonOption = new Option("--json");
+ var devflowCommand = DevFlowCommands.CreateDevFlowCommand(jsonOption);
+
+ Assert.Contains(devflowCommand.Subcommands, command => command.Name == "init");
+ }
+
private static void AssertNoWhitespaceAliases(Command command)
{
foreach (var option in command.Options)
diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/DevFlowInitTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/DevFlowInitTests.cs
new file mode 100644
index 000000000..460c24541
--- /dev/null
+++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/DevFlowInitTests.cs
@@ -0,0 +1,739 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json;
+using System.Threading;
+using Microsoft.Maui.Cli.DevFlow;
+using Microsoft.Maui.Cli.DevFlow.Init;
+using Xunit;
+
+namespace Microsoft.Maui.Cli.UnitTests;
+
+[Collection("CLI")]
+[RequiresUnreferencedCode("DevFlow init tests exercise MSBuild evaluation which uses reflection-heavy APIs.")]
+[RequiresDynamicCode("DevFlow init tests exercise MSBuild evaluation which uses reflection-heavy APIs.")]
+public sealed class DevFlowInitTests
+{
+ static readonly SemaphoreSlim s_currentDirectoryGate = new(1, 1);
+
+ [Fact]
+ public void ManifestLoader_LoadsEmbeddedManifest()
+ {
+ var manifest = DevFlowInitManifestLoader.Load();
+
+ Assert.Equal(1, manifest.SchemaVersion);
+ Assert.Equal("Microsoft.Maui.DevFlow.Agent", manifest.Packages.Agent.PackageId);
+ Assert.Equal("Microsoft.Maui.DevFlow.Blazor", manifest.Packages.Blazor.PackageId);
+ Assert.Contains(manifest.Hosts, host => host.Id == "claude");
+ Assert.Contains(manifest.Hosts, host => host.Id == "copilot");
+ }
+
+ [Fact]
+ public void ProjectScanner_DescribeProject_DetectsBlazorProject()
+ {
+ using var workspace = new TempWorkspace();
+ var projectPath = workspace.CreateMauiProject("BlazorApp", blazor: true);
+
+ var candidate = DevFlowProjectScanner.DescribeProject(workspace.RootPath, projectPath);
+
+ Assert.NotNull(candidate);
+ Assert.Equal("standard-maui-blazor", candidate!.Flavor);
+ Assert.True(candidate.IsSupported);
+ Assert.True(candidate.NeedsBlazor);
+ Assert.False(candidate.IsAlreadyIntegrated);
+ }
+
+ [Fact]
+ public void MauiProgramPatcher_EnsureRegistration_AddsDevFlowCallsAndUsings()
+ {
+ using var workspace = new TempWorkspace();
+ var mauiProgramPath = workspace.WriteFile("MauiProgram.cs", """
+using Microsoft.Extensions.DependencyInjection;
+
+namespace SampleApp;
+
+public static class MauiProgram
+{
+ public static MauiApp CreateMauiApp()
+ {
+ var builder = MauiApp.CreateBuilder();
+ builder.Services.AddMauiBlazorWebView();
+
+ return builder.Build();
+ }
+}
+""");
+
+ var result = MauiProgramPatcher.EnsureRegistration(mauiProgramPath, includeBlazor: true, isGtk: false, dryRun: false);
+
+ Assert.Equal(DevFlowInitStatus.Success, result.Status);
+
+ var updated = File.ReadAllText(mauiProgramPath);
+ Assert.Contains("using Microsoft.Maui.DevFlow.Agent;", updated);
+ Assert.Contains("using Microsoft.Maui.DevFlow.Blazor;", updated);
+ Assert.Contains("#if DEBUG", updated);
+ Assert.Contains("builder.AddMauiDevFlowAgent();", updated);
+ Assert.Contains("builder.AddMauiBlazorDevFlowTools();", updated);
+
+ var secondPass = MauiProgramPatcher.EnsureRegistration(mauiProgramPath, includeBlazor: true, isGtk: false, dryRun: false);
+ Assert.Equal(DevFlowInitStatus.AlreadyPresent, secondPass.Status);
+ }
+
+ [Fact]
+ public void ProjectUpdater_Apply_WithCentralPackageManagement_WritesPackageVersionToDirectoryPackagesProps()
+ {
+ using var workspace = new TempWorkspace();
+ workspace.WriteFile("Directory.Packages.props", """
+
+
+ true
+
+
+
+""");
+ var projectPath = workspace.CreateMauiProject("CpmApp");
+ var candidate = DevFlowProjectScanner.DescribeProject(workspace.RootPath, projectPath);
+ Assert.NotNull(candidate);
+
+ var result = DevFlowProjectUpdater.Apply(candidate!, DevFlowInitManifestLoader.Load(), dryRun: false, workspaceRoot: workspace.RootPath);
+
+ Assert.Equal(DevFlowInitStatus.Success, result.OverallStatus);
+
+ var projectText = File.ReadAllText(projectPath);
+ Assert.Contains("PackageReference Include=\"Microsoft.Maui.DevFlow.Agent\"", projectText, StringComparison.Ordinal);
+ Assert.DoesNotContain("PackageReference Include=\"Microsoft.Maui.DevFlow.Agent\" Version=", projectText, StringComparison.Ordinal);
+
+ var packagesText = File.ReadAllText(Path.Combine(workspace.RootPath, "Directory.Packages.props"));
+ Assert.Contains("PackageVersion Include=\"Microsoft.Maui.DevFlow.Agent\"", packagesText, StringComparison.Ordinal);
+ Assert.Contains(DevFlowInitManifestLoader.Load().Packages.Agent.Version, packagesText);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_NoAi_WritesReportAndUpdatesProject()
+ {
+ using var workspace = new TempWorkspace();
+ var projectPath = workspace.CreateMauiProject("SampleApp");
+ var mauiProgramPath = Path.Combine(Path.GetDirectoryName(projectPath)!, "MauiProgram.cs");
+ var reportPath = Path.Combine(workspace.RootPath, "MAUI-DEVFLOW-INIT-REPORT.md");
+ var output = new TestOutputWriter();
+
+ await s_currentDirectoryGate.WaitAsync();
+ try
+ {
+ var originalDirectory = Directory.GetCurrentDirectory();
+ Directory.SetCurrentDirectory(workspace.RootPath);
+ try
+ {
+ var success = await DevFlowInitCommand.ExecuteAsync(
+ new DevFlowInitOptions
+ {
+ NoAi = true
+ },
+ output);
+
+ Assert.True(success);
+ }
+ finally
+ {
+ Directory.SetCurrentDirectory(originalDirectory);
+ }
+ }
+ finally
+ {
+ s_currentDirectoryGate.Release();
+ }
+
+ var report = Assert.IsType(output.LastResult);
+ Assert.Equal(DevFlowInitStatus.Success, report.OverallStatus);
+ Assert.True(File.Exists(reportPath));
+ Assert.Contains("Microsoft.Maui.DevFlow.Agent", File.ReadAllText(projectPath));
+ Assert.Contains("builder.AddMauiDevFlowAgent();", File.ReadAllText(mauiProgramPath));
+ Assert.Contains("`disabled`", File.ReadAllText(reportPath));
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_MultipleProjectsInCiMode_RequiresExplicitSelection()
+ {
+ using var workspace = new TempWorkspace();
+ workspace.CreateMauiProject("AppOne");
+ workspace.CreateMauiProject("AppTwo");
+ var reportPath = Path.Combine(workspace.RootPath, "MAUI-DEVFLOW-INIT-REPORT.md");
+ var output = new TestOutputWriter();
+
+ await s_currentDirectoryGate.WaitAsync();
+ try
+ {
+ var originalDirectory = Directory.GetCurrentDirectory();
+ Directory.SetCurrentDirectory(workspace.RootPath);
+ try
+ {
+ var success = await DevFlowInitCommand.ExecuteAsync(
+ new DevFlowInitOptions
+ {
+ NoAi = true,
+ Ci = true
+ },
+ output);
+
+ Assert.False(success);
+ }
+ finally
+ {
+ Directory.SetCurrentDirectory(originalDirectory);
+ }
+ }
+ finally
+ {
+ s_currentDirectoryGate.Release();
+ }
+
+ var report = Assert.IsType(output.LastResult);
+ Assert.Equal(DevFlowInitStatus.Failed, report.OverallStatus);
+ Assert.Contains(report.Notes, note => note.Contains("Multiple eligible MAUI projects were found.", StringComparison.Ordinal));
+ Assert.True(File.Exists(reportPath));
+ }
+
+ [Fact]
+ public void ManifestLoader_ContainsGtkPackages()
+ {
+ var manifest = DevFlowInitManifestLoader.Load();
+
+ Assert.Equal("Microsoft.Maui.DevFlow.Agent.Gtk", manifest.Packages.AgentGtk.PackageId);
+ Assert.Equal("Microsoft.Maui.DevFlow.Blazor.Gtk", manifest.Packages.BlazorGtk.PackageId);
+ Assert.NotEmpty(manifest.Packages.AgentGtk.Version);
+ Assert.NotEmpty(manifest.Packages.BlazorGtk.Version);
+ }
+
+ [Fact]
+ public void ProjectScanner_DescribeProject_DetectsGtkProject()
+ {
+ using var workspace = new TempWorkspace();
+ var projectPath = workspace.CreateGtkProject("GtkApp");
+
+ var candidate = DevFlowProjectScanner.DescribeProject(workspace.RootPath, projectPath);
+
+ Assert.NotNull(candidate);
+ Assert.Equal("gtk", candidate!.Flavor);
+ Assert.True(candidate.IsSupported);
+ Assert.False(candidate.IsAlreadyIntegrated);
+ }
+
+ [Fact]
+ public void ProjectScanner_DescribeProject_DetectsGtkBlazorProject()
+ {
+ using var workspace = new TempWorkspace();
+ var projectPath = workspace.CreateGtkProject("GtkBlazorApp", blazor: true);
+
+ var candidate = DevFlowProjectScanner.DescribeProject(workspace.RootPath, projectPath);
+
+ Assert.NotNull(candidate);
+ Assert.Equal("gtk-blazor", candidate!.Flavor);
+ Assert.True(candidate.IsSupported);
+ Assert.True(candidate.NeedsBlazor);
+ }
+
+ [Fact]
+ public void ProjectUpdater_Apply_GtkProject_UsesGtkPackages()
+ {
+ using var workspace = new TempWorkspace();
+ var projectPath = workspace.CreateGtkProject("GtkApp");
+ var candidate = DevFlowProjectScanner.DescribeProject(workspace.RootPath, projectPath);
+ Assert.NotNull(candidate);
+
+ var result = DevFlowProjectUpdater.Apply(candidate!, DevFlowInitManifestLoader.Load(), dryRun: false);
+
+ Assert.Equal(DevFlowInitStatus.Success, result.OverallStatus);
+ var projectText = File.ReadAllText(projectPath);
+ Assert.Contains("Microsoft.Maui.DevFlow.Agent.Gtk", projectText, StringComparison.Ordinal);
+ Assert.DoesNotContain("\"Microsoft.Maui.DevFlow.Agent\"", projectText, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void MauiProgramPatcher_GtkProject_UsesGtkNamespaces()
+ {
+ using var workspace = new TempWorkspace();
+ var mauiProgramPath = workspace.WriteFile("MauiProgram.cs", """
+using Microsoft.Extensions.DependencyInjection;
+
+namespace GtkApp;
+
+public static class MauiProgram
+{
+ public static MauiApp CreateMauiApp()
+ {
+ var builder = MauiApp.CreateBuilder();
+
+ return builder.Build();
+ }
+}
+""");
+
+ var result = MauiProgramPatcher.EnsureRegistration(mauiProgramPath, includeBlazor: false, isGtk: true, dryRun: false);
+
+ Assert.Equal(DevFlowInitStatus.Success, result.Status);
+
+ var updated = File.ReadAllText(mauiProgramPath);
+ Assert.Contains("using Microsoft.Maui.DevFlow.Agent.Gtk;", updated);
+ Assert.DoesNotContain("using Microsoft.Maui.DevFlow.Agent;", updated);
+ Assert.Contains("builder.AddMauiDevFlowAgent();", updated);
+ }
+
+ [Fact]
+ public void MauiProgramPatcher_GtkBlazorProject_UsesGtkBlazorNamespaces()
+ {
+ using var workspace = new TempWorkspace();
+ var mauiProgramPath = workspace.WriteFile("MauiProgram.cs", """
+using Microsoft.Extensions.DependencyInjection;
+
+namespace GtkBlazorApp;
+
+public static class MauiProgram
+{
+ public static MauiApp CreateMauiApp()
+ {
+ var builder = MauiApp.CreateBuilder();
+ builder.Services.AddMauiBlazorWebView();
+
+ return builder.Build();
+ }
+}
+""");
+
+ var result = MauiProgramPatcher.EnsureRegistration(mauiProgramPath, includeBlazor: true, isGtk: true, dryRun: false);
+
+ Assert.Equal(DevFlowInitStatus.Success, result.Status);
+
+ var updated = File.ReadAllText(mauiProgramPath);
+ Assert.Contains("using Microsoft.Maui.DevFlow.Agent.Gtk;", updated);
+ Assert.Contains("using Microsoft.Maui.DevFlow.Blazor.Gtk;", updated);
+ Assert.DoesNotContain("using Microsoft.Maui.DevFlow.Agent;", updated);
+ Assert.DoesNotContain("using Microsoft.Maui.DevFlow.Blazor;", updated);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_AlreadyOnboarded_ReportsAlreadyPresent()
+ {
+ using var workspace = new TempWorkspace();
+ var projectPath = workspace.CreateMauiProject("AlreadyDone");
+ var mauiProgramDir = Path.GetDirectoryName(projectPath)!;
+
+ // Manually add DevFlow package and registration to simulate already-onboarded state
+ var csproj = File.ReadAllText(projectPath);
+ csproj = csproj.Replace("", """
+
+
+
+
+""");
+ File.WriteAllText(projectPath, csproj);
+
+ var mauiProgramText = File.ReadAllText(Path.Combine(mauiProgramDir, "MauiProgram.cs"));
+ mauiProgramText = mauiProgramText.Replace(
+ "return builder.Build();",
+ """
+#if DEBUG
+ builder.AddMauiDevFlowAgent();
+#endif
+
+ return builder.Build();
+""");
+ mauiProgramText = "using Microsoft.Maui.DevFlow.Agent;\n" + mauiProgramText;
+ File.WriteAllText(Path.Combine(mauiProgramDir, "MauiProgram.cs"), mauiProgramText);
+
+ var output = new TestOutputWriter();
+ await s_currentDirectoryGate.WaitAsync();
+ try
+ {
+ var originalDirectory = Directory.GetCurrentDirectory();
+ Directory.SetCurrentDirectory(workspace.RootPath);
+ try
+ {
+ var success = await DevFlowInitCommand.ExecuteAsync(
+ new DevFlowInitOptions { NoAi = true },
+ output);
+
+ Assert.True(success);
+ }
+ finally
+ {
+ Directory.SetCurrentDirectory(originalDirectory);
+ }
+ }
+ finally
+ {
+ s_currentDirectoryGate.Release();
+ }
+
+ var report = Assert.IsType(output.LastResult);
+ Assert.Equal(DevFlowInitStatus.AlreadyPresent, report.OverallStatus);
+ Assert.Contains(report.Projects, p => p.OverallStatus == DevFlowInitStatus.AlreadyPresent);
+ // Phase 3: already-onboarded projects include verification commands
+ var alreadyProject = report.Projects.First(p => p.OverallStatus == DevFlowInitStatus.AlreadyPresent);
+ Assert.Contains(alreadyProject.VerificationCommands, cmd => cmd.Contains("dotnet build", StringComparison.Ordinal));
+ Assert.Contains(alreadyProject.VerificationCommands, cmd => cmd.Contains("maui devflow wait", StringComparison.Ordinal));
+ // Phase 3: already-onboarded includes suggestion to use --force
+ Assert.Contains(alreadyProject.ManualSteps, step => step.Contains("--force", StringComparison.Ordinal));
+ // Phase 3: NextSteps populated for already-onboarded workspace
+ Assert.NotEmpty(report.NextSteps);
+ Assert.Contains(report.NextSteps, s => s.Contains("already integrated", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_JsonSidecar_WrittenAlongsideMd()
+ {
+ using var workspace = new TempWorkspace();
+ workspace.CreateMauiProject("JsonTest");
+ var output = new TestOutputWriter();
+
+ await s_currentDirectoryGate.WaitAsync();
+ try
+ {
+ var originalDirectory = Directory.GetCurrentDirectory();
+ Directory.SetCurrentDirectory(workspace.RootPath);
+ try
+ {
+ var success = await DevFlowInitCommand.ExecuteAsync(
+ new DevFlowInitOptions { NoAi = true },
+ output);
+
+ Assert.True(success);
+ }
+ finally
+ {
+ Directory.SetCurrentDirectory(originalDirectory);
+ }
+ }
+ finally
+ {
+ s_currentDirectoryGate.Release();
+ }
+
+ var report = Assert.IsType(output.LastResult);
+ // JSON sidecar is written alongside the markdown report
+ Assert.True(File.Exists(report.JsonReportPath), $"JSON sidecar not found at {report.JsonReportPath}");
+ Assert.True(File.Exists(report.ReportPath), $"Markdown report not found at {report.ReportPath}");
+
+ // JSON sidecar is valid JSON containing expected fields
+ var jsonContent = File.ReadAllText(report.JsonReportPath);
+ var doc = JsonDocument.Parse(jsonContent);
+ Assert.Equal(report.WorkspacePath, doc.RootElement.GetProperty("workspacePath").GetString());
+ Assert.Equal(report.OverallStatus, doc.RootElement.GetProperty("overallStatus").GetString());
+ Assert.True(doc.RootElement.TryGetProperty("nextSteps", out var nextSteps));
+ Assert.Equal(JsonValueKind.Array, nextSteps.ValueKind);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_Force_ReappliesAlreadyOnboarded()
+ {
+ using var workspace = new TempWorkspace();
+ var projectPath = workspace.CreateMauiProject("ForceApp");
+ var mauiProgramDir = Path.GetDirectoryName(projectPath)!;
+
+ // Simulate already-onboarded with old package version
+ var csproj = File.ReadAllText(projectPath);
+ csproj = csproj.Replace("", """
+
+
+
+
+""");
+ File.WriteAllText(projectPath, csproj);
+
+ var mauiProgramText = File.ReadAllText(Path.Combine(mauiProgramDir, "MauiProgram.cs"));
+ mauiProgramText = mauiProgramText.Replace(
+ "return builder.Build();",
+ """
+#if DEBUG
+ builder.AddMauiDevFlowAgent();
+#endif
+
+ return builder.Build();
+""");
+ mauiProgramText = "using Microsoft.Maui.DevFlow.Agent;\n" + mauiProgramText;
+ File.WriteAllText(Path.Combine(mauiProgramDir, "MauiProgram.cs"), mauiProgramText);
+
+ var output = new TestOutputWriter();
+ await s_currentDirectoryGate.WaitAsync();
+ try
+ {
+ var originalDirectory = Directory.GetCurrentDirectory();
+ Directory.SetCurrentDirectory(workspace.RootPath);
+ try
+ {
+ // Without --force: reports already present
+ var successWithout = await DevFlowInitCommand.ExecuteAsync(
+ new DevFlowInitOptions { NoAi = true },
+ output);
+ Assert.True(successWithout);
+ var reportWithout = Assert.IsType(output.LastResult);
+ Assert.Equal(DevFlowInitStatus.AlreadyPresent, reportWithout.OverallStatus);
+
+ // With --force: re-processes the project
+ var successWith = await DevFlowInitCommand.ExecuteAsync(
+ new DevFlowInitOptions { NoAi = true, Force = true },
+ output);
+ Assert.True(successWith);
+ var reportWith = Assert.IsType(output.LastResult);
+ // With force, the project should be processed (success or already_present for the operations)
+ Assert.Contains(reportWith.Projects, p =>
+ p.OverallStatus != DevFlowInitStatus.Skipped);
+ }
+ finally
+ {
+ Directory.SetCurrentDirectory(originalDirectory);
+ }
+ }
+ finally
+ {
+ s_currentDirectoryGate.Release();
+ }
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_SuccessfulInit_PopulatesNextSteps()
+ {
+ using var workspace = new TempWorkspace();
+ workspace.CreateMauiProject("NextStepsApp");
+ var output = new TestOutputWriter();
+
+ await s_currentDirectoryGate.WaitAsync();
+ try
+ {
+ var originalDirectory = Directory.GetCurrentDirectory();
+ Directory.SetCurrentDirectory(workspace.RootPath);
+ try
+ {
+ var success = await DevFlowInitCommand.ExecuteAsync(
+ new DevFlowInitOptions { NoAi = true },
+ output);
+
+ Assert.True(success);
+ }
+ finally
+ {
+ Directory.SetCurrentDirectory(originalDirectory);
+ }
+ }
+ finally
+ {
+ s_currentDirectoryGate.Release();
+ }
+
+ var report = Assert.IsType(output.LastResult);
+ Assert.Equal(DevFlowInitStatus.Success, report.OverallStatus);
+ Assert.NotEmpty(report.NextSteps);
+ Assert.Contains(report.NextSteps, s => s.Contains("maui devflow wait", StringComparison.Ordinal));
+ Assert.Contains(report.NextSteps, s => s.Contains("maui devflow tree", StringComparison.Ordinal));
+
+ // Per-project verification commands populated for successful projects
+ var project = report.Projects.First(p => p.OverallStatus == DevFlowInitStatus.Success);
+ Assert.NotEmpty(project.VerificationCommands);
+ Assert.Contains(project.VerificationCommands, cmd => cmd.Contains("dotnet build", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_BlazorProject_AddsBlazorPackageAndRegistration()
+ {
+ using var workspace = new TempWorkspace();
+ var projectPath = workspace.CreateMauiProject("BlazorE2E", blazor: true);
+ var mauiProgramDir = Path.GetDirectoryName(projectPath)!;
+ var output = new TestOutputWriter();
+
+ await s_currentDirectoryGate.WaitAsync();
+ try
+ {
+ var originalDirectory = Directory.GetCurrentDirectory();
+ Directory.SetCurrentDirectory(workspace.RootPath);
+ try
+ {
+ var success = await DevFlowInitCommand.ExecuteAsync(
+ new DevFlowInitOptions { NoAi = true },
+ output);
+
+ Assert.True(success);
+ }
+ finally
+ {
+ Directory.SetCurrentDirectory(originalDirectory);
+ }
+ }
+ finally
+ {
+ s_currentDirectoryGate.Release();
+ }
+
+ var report = Assert.IsType(output.LastResult);
+ Assert.Equal(DevFlowInitStatus.Success, report.OverallStatus);
+
+ var projectText = File.ReadAllText(projectPath);
+ Assert.Contains("Microsoft.Maui.DevFlow.Agent", projectText, StringComparison.Ordinal);
+ Assert.Contains("Microsoft.Maui.DevFlow.Blazor", projectText, StringComparison.Ordinal);
+
+ var mauiProgramText = File.ReadAllText(Path.Combine(mauiProgramDir, "MauiProgram.cs"));
+ Assert.Contains("builder.AddMauiDevFlowAgent();", mauiProgramText);
+ Assert.Contains("builder.AddMauiBlazorDevFlowTools();", mauiProgramText);
+ Assert.Contains("using Microsoft.Maui.DevFlow.Agent;", mauiProgramText);
+ Assert.Contains("using Microsoft.Maui.DevFlow.Blazor;", mauiProgramText);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_EmptyWorkspace_ReportsNoProjects()
+ {
+ using var workspace = new TempWorkspace();
+ var output = new TestOutputWriter();
+
+ await s_currentDirectoryGate.WaitAsync();
+ try
+ {
+ var originalDirectory = Directory.GetCurrentDirectory();
+ Directory.SetCurrentDirectory(workspace.RootPath);
+ try
+ {
+ var success = await DevFlowInitCommand.ExecuteAsync(
+ new DevFlowInitOptions { NoAi = true },
+ output);
+
+ Assert.False(success);
+ }
+ finally
+ {
+ Directory.SetCurrentDirectory(originalDirectory);
+ }
+ }
+ finally
+ {
+ s_currentDirectoryGate.Release();
+ }
+
+ var report = Assert.IsType(output.LastResult);
+ Assert.Equal(DevFlowInitStatus.ManualRequired, report.OverallStatus);
+ Assert.Contains(report.Notes, n => n.Contains("No MAUI projects", StringComparison.Ordinal));
+ }
+
+ sealed class TempWorkspace : IDisposable
+ {
+ public TempWorkspace()
+ {
+ RootPath = Path.Combine(Path.GetTempPath(), "maui-cli-tests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(RootPath);
+ }
+
+ public string RootPath { get; }
+
+ public string CreateMauiProject(string name, bool blazor = false)
+ {
+ var projectDirectory = Path.Combine(RootPath, name);
+ Directory.CreateDirectory(projectDirectory);
+
+ WriteFile(Path.Combine(name, $"{name}.csproj"), $$"""
+
+
+ net10.0-android;net10.0-ios
+ true
+ Exe
+
+{{(blazor ? """
+
+
+
+""" : "")}}
+
+""");
+
+ WriteFile(Path.Combine(name, "MauiProgram.cs"), $$"""
+using Microsoft.Extensions.DependencyInjection;
+
+namespace {{name}};
+
+public static class MauiProgram
+{
+ public static MauiApp CreateMauiApp()
+ {
+ var builder = MauiApp.CreateBuilder();
+{{(blazor ? " builder.Services.AddMauiBlazorWebView();\n" : "")}}
+ return builder.Build();
+ }
+}
+""");
+
+ return Path.Combine(projectDirectory, $"{name}.csproj");
+ }
+
+ public string CreateGtkProject(string name, bool blazor = false)
+ {
+ var projectDirectory = Path.Combine(RootPath, name);
+ Directory.CreateDirectory(projectDirectory);
+
+ WriteFile(Path.Combine(name, $"{name}.csproj"), $$"""
+
+
+ net10.0
+ true
+ Exe
+
+
+
+{{(blazor ? """
+
+""" : "")}}
+
+
+""");
+
+ WriteFile(Path.Combine(name, "MauiProgram.cs"), $$"""
+using Microsoft.Extensions.DependencyInjection;
+
+namespace {{name}};
+
+public static class MauiProgram
+{
+ public static MauiApp CreateMauiApp()
+ {
+ var builder = MauiApp.CreateBuilder();
+{{(blazor ? " builder.Services.AddMauiBlazorWebView();\n" : "")}}
+ return builder.Build();
+ }
+}
+""");
+
+ return Path.Combine(projectDirectory, $"{name}.csproj");
+ }
+
+ public string WriteFile(string relativePath, string contents)
+ {
+ var fullPath = Path.Combine(RootPath, relativePath);
+ var directory = Path.GetDirectoryName(fullPath);
+ if (!string.IsNullOrEmpty(directory))
+ Directory.CreateDirectory(directory);
+
+ File.WriteAllText(fullPath, contents.ReplaceLineEndings(Environment.NewLine));
+ return fullPath;
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ if (Directory.Exists(RootPath))
+ Directory.Delete(RootPath, recursive: true);
+ }
+ catch
+ {
+ // Best effort cleanup for temp test data.
+ }
+ }
+ }
+
+ sealed class TestOutputWriter : IDevFlowOutputWriter
+ {
+ public object? LastResult { get; private set; }
+ public string? LastError { get; private set; }
+
+ public bool ResolveJsonMode(bool jsonFlag, bool noJsonFlag) => jsonFlag && !noJsonFlag;
+ public void WriteResult(T data, bool json, Action? humanFormatter = null) => LastResult = data;
+ public void WriteRawJson(string jsonString) => LastResult = jsonString;
+ public void WriteJsonElement(JsonElement element, bool json) => LastResult = element.Clone();
+ public void WriteActionResult(bool success, string action, string? elementId, bool json, string? humanMessage = null) => LastResult = success;
+ public void WriteError(string message, bool json, string errorType = "RuntimeError", bool retryable = false, string[]? suggestions = null) => LastError = message;
+ public void WriteJsonLine(T data) => LastResult = data;
+ public string FormatJson(T data) => JsonSerializer.Serialize(data);
+ }
+}
diff --git a/src/Cli/Microsoft.Maui.Cli/DevFlow/Broker/BrokerClient.cs b/src/Cli/Microsoft.Maui.Cli/DevFlow/Broker/BrokerClient.cs
index ec0563696..9d8972634 100644
--- a/src/Cli/Microsoft.Maui.Cli/DevFlow/Broker/BrokerClient.cs
+++ b/src/Cli/Microsoft.Maui.Cli/DevFlow/Broker/BrokerClient.cs
@@ -106,7 +106,7 @@ public static async Task ShutdownBrokerAsync(int? port = null)
}
}
- private static async Task IsBrokerAliveAsync(int port)
+ internal static async Task IsBrokerAliveAsync(int port)
{
try
{
@@ -178,10 +178,10 @@ private static async Task IsBrokerAliveAsync(int port)
///
public static int? ReadConfigPort()
{
- var configPath = Path.Combine(Directory.GetCurrentDirectory(), ".mauidevflow");
- if (!File.Exists(configPath)) return null;
try
{
+ var configPath = Path.Combine(Directory.GetCurrentDirectory(), ".mauidevflow");
+ if (!File.Exists(configPath)) return null;
var json = CliJson.ParseElement(File.ReadAllText(configPath));
if (json.TryGetProperty("port", out var portEl) && portEl.TryGetInt32(out var p))
return p;
diff --git a/src/Cli/Microsoft.Maui.Cli/DevFlow/DevFlowCliJsonContext.cs b/src/Cli/Microsoft.Maui.Cli/DevFlow/DevFlowCliJsonContext.cs
index 8930a2c9c..39a51dd31 100644
--- a/src/Cli/Microsoft.Maui.Cli/DevFlow/DevFlowCliJsonContext.cs
+++ b/src/Cli/Microsoft.Maui.Cli/DevFlow/DevFlowCliJsonContext.cs
@@ -1,5 +1,6 @@
using System.Text.Json.Serialization;
using Microsoft.Maui.Cli.DevFlow.Broker;
+using Microsoft.Maui.Cli.DevFlow.Init;
using Microsoft.Maui.DevFlow.Driver;
namespace Microsoft.Maui.Cli.DevFlow;
@@ -22,4 +23,5 @@ namespace Microsoft.Maui.Cli.DevFlow;
[JsonSerializable(typeof(AgentRegistration[]))]
[JsonSerializable(typeof(BrokerState))]
[JsonSerializable(typeof(RegistrationMessage))]
+[JsonSerializable(typeof(DevFlowInitReport))]
internal sealed partial class DevFlowCliJsonContext : JsonSerializerContext;
diff --git a/src/Cli/Microsoft.Maui.Cli/DevFlow/DevFlowCommands.cs b/src/Cli/Microsoft.Maui.Cli/DevFlow/DevFlowCommands.cs
index 3a44ea6b3..ba2dba1c8 100644
--- a/src/Cli/Microsoft.Maui.Cli/DevFlow/DevFlowCommands.cs
+++ b/src/Cli/Microsoft.Maui.Cli/DevFlow/DevFlowCommands.cs
@@ -6,6 +6,7 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Maui.Cli.DevFlow.Init;
using Microsoft.Maui.Cli.Utils;
namespace Microsoft.Maui.Cli.DevFlow;
@@ -1245,6 +1246,53 @@ await MauiScrollAsync(host, port, isJson,
});
devflowCommand.Add(listCmd);
+ // ===== init command (workspace onboarding) =====
+ var initProjectOption = new Option("--project") { Description = "Path to a specific app .csproj or directory containing it", DefaultValueFactory = _ => null };
+ var initAllOption = new Option("--all") { Description = "Onboard all eligible MAUI projects found in the workspace", DefaultValueFactory = _ => false };
+ var initBlazorOption = new Option("--blazor") { Description = "Force Blazor onboarding even if it is not auto-detected", DefaultValueFactory = _ => false };
+ var initNoBlazorOption = new Option("--no-blazor") { Description = "Skip Blazor package/tool registration even if it is auto-detected", DefaultValueFactory = _ => false };
+ var initGtkOption = new Option("--gtk") { Description = "Force GTK/Linux package selection instead of standard MAUI packages", DefaultValueFactory = _ => false };
+ var initNewOption = new Option("--new") { Description = "Scaffold a new MAUI project before onboarding (maui or maui-blazor)", DefaultValueFactory = _ => null };
+ var initNewNameOption = new Option("--name", "-n") { Description = "Name for the scaffolded project (used with --new)", DefaultValueFactory = _ => null };
+ var initNoAiOption = new Option("--no-ai") { Description = "Skip AI host bootstrap", DefaultValueFactory = _ => false };
+ var initAiHostOption = new Option("--ai-host") { Description = "Force a specific AI host from the bootstrap manifest", DefaultValueFactory = _ => null };
+ var initAiLocalOnlyOption = new Option("--ai-local-only") { Description = "Use repo-local skill sync only; skip marketplace/plugin automation", DefaultValueFactory = _ => false };
+ var initForceOption = new Option("--force") { Description = "Re-apply onboarding even for already-integrated projects (useful for updating package versions)", DefaultValueFactory = _ => false };
+ var initCmd = new Command("init", "Onboard one or more MAUI projects for DevFlow")
+ {
+ initProjectOption, initAllOption, initBlazorOption, initNoBlazorOption, initGtkOption,
+ initNewOption, initNewNameOption, initNoAiOption, initAiHostOption, initAiLocalOnlyOption,
+ initForceOption
+ };
+ initCmd.SetAction(async (ctx, ct) =>
+ {
+ var success = await DevFlowInitCommand.ExecuteAsync(
+ new DevFlowInitOptions
+ {
+ Project = ctx.GetValue(initProjectOption),
+ All = ctx.GetValue(initAllOption),
+ ForceBlazor = ctx.GetValue(initBlazorOption),
+ DisableBlazor = ctx.GetValue(initNoBlazorOption),
+ ForceGtk = ctx.GetValue(initGtkOption),
+ Force = ctx.GetValue(initForceOption),
+ NewTemplate = ctx.GetValue(initNewOption),
+ NewName = ctx.GetValue(initNewNameOption),
+ NoAi = ctx.GetValue(initNoAiOption),
+ AiHost = ctx.GetValue(initAiHostOption),
+ AiLocalOnly = ctx.GetValue(initAiLocalOnlyOption),
+ Json = ctx.GetValue(jsonOption),
+ NoJson = ctx.GetValue(noJsonOption),
+ DryRun = ctx.GetValue(GlobalOptions.DryRunOption),
+ Ci = ctx.GetValue(GlobalOptions.CiOption)
+ },
+ output,
+ ct);
+
+ if (!success)
+ _errorOccurred = true;
+ });
+ devflowCommand.Add(initCmd);
+
// ===== diagnose command (end-to-end diagnostics) =====
var diagnoseCmd = new Command("diagnose", "Check DevFlow health: broker, agents, and project integration");
diagnoseCmd.SetAction(async (ctx, ct) =>
@@ -2049,6 +2097,7 @@ private static async Task MauiAssertAsync(string host, int port, bool json, stri
private static List GetCommandDescriptions() => new()
{
+ new("init", "Onboard one or more MAUI projects for DevFlow", true),
new("ui status", "Check agent connection and app info", false),
new("ui tree", "Dump visual element tree", false),
new("ui query", "Find elements by type, automationId, text, or CSS selector", false),
@@ -3771,16 +3820,21 @@ private static int ResolveAgentPort()
{
try
{
- var port = Broker.BrokerClient.ResolveAgentPortForProjectAsync().GetAwaiter().GetResult();
- if (port.HasValue) return port.Value;
-
- // No single match — check config file fallback
+ // Only check existing broker/config state — don't auto-start the broker.
+ // Commands that need the broker call EnsureBrokerRunningAsync themselves.
var configPort = Broker.BrokerClient.ReadConfigPort();
if (configPort.HasValue) return configPort.Value;
+ var brokerPort = Broker.BrokerClient.ReadBrokerPortPublic() ?? Broker.BrokerServer.DefaultPort;
+
+ if (!Broker.BrokerClient.IsBrokerAliveAsync(brokerPort).GetAwaiter().GetResult())
+ return configPort ?? 9223;
+
+ var port = Broker.BrokerClient.ResolveAgentPortAsync(brokerPort).GetAwaiter().GetResult();
+ if (port.HasValue) return port.Value;
+
// Multiple agents, can't disambiguate — show them so the caller
// (human or AI agent) can re-run with --agent-port
- var brokerPort = Broker.BrokerClient.ReadBrokerPortPublic() ?? Broker.BrokerServer.DefaultPort;
var agents = Broker.BrokerClient.ListAgentsAsync(brokerPort).GetAwaiter().GetResult();
if (agents != null && agents.Length > 1)
{
@@ -3796,7 +3850,7 @@ private static int ResolveAgentPort()
}
catch { /* broker unavailable, fall through */ }
- return Broker.BrokerClient.ReadConfigPort() ?? 9223;
+ return 9223;
}
// ===== Broker Commands =====
diff --git a/src/Cli/Microsoft.Maui.Cli/DevFlow/Init/AiHostBootstrapper.cs b/src/Cli/Microsoft.Maui.Cli/DevFlow/Init/AiHostBootstrapper.cs
new file mode 100644
index 000000000..ab2adf27f
--- /dev/null
+++ b/src/Cli/Microsoft.Maui.Cli/DevFlow/Init/AiHostBootstrapper.cs
@@ -0,0 +1,173 @@
+using Spectre.Console;
+
+namespace Microsoft.Maui.Cli.DevFlow.Init;
+
+internal static class AiHostBootstrapper
+{
+ public static async Task RunAsync(
+ DevFlowInitManifest manifest,
+ string workspaceRoot,
+ string? explicitHost,
+ bool noAi,
+ bool aiLocalOnly,
+ bool interactive,
+ bool dryRun,
+ CancellationToken cancellationToken = default)
+ {
+ if (noAi)
+ {
+ return new DevFlowAiBootstrapResult
+ {
+ OverallStatus = DevFlowInitStatus.Disabled,
+ BootstrapMode = "disabled"
+ };
+ }
+
+ var detectedHosts = manifest.Hosts
+ .Where(host => IsHostDetected(host, workspaceRoot))
+ .ToList();
+
+ var result = new DevFlowAiBootstrapResult
+ {
+ OverallStatus = DevFlowInitStatus.Skipped,
+ BootstrapMode = "manual"
+ };
+ result.DetectedHosts.AddRange(detectedHosts.Select(host => host.DisplayName));
+
+ var selectedHost = ResolveHost(manifest, detectedHosts, explicitHost, interactive);
+ if (selectedHost == null)
+ {
+ result.OverallStatus = DevFlowInitStatus.ManualRequired;
+ result.ManualSteps.Add("No AI host could be selected automatically.");
+ result.ManualSteps.Add("Re-run `maui devflow init --ai-host ` or set up the desired host manually.");
+ return result;
+ }
+
+ result.SelectedHostId = selectedHost.Id;
+ result.SelectedHostDisplayName = selectedHost.DisplayName;
+
+ var fallback = selectedHost.RepoLocalFallbacks.FirstOrDefault();
+ if (!aiLocalOnly && selectedHost.MarketplaceInstalls.Any(install => !string.Equals(install.InstallStrategy, "manual", StringComparison.OrdinalIgnoreCase)))
+ {
+ // Reserved for future host-native automation strategies.
+ }
+
+ if (fallback != null)
+ {
+ using var http = GitHubDirectorySync.CreateHttpClient();
+ var destinationRoot = Path.Combine(workspaceRoot, fallback.TargetPathTemplate.Replace('/', Path.DirectorySeparatorChar));
+ try
+ {
+ var syncResult = await GitHubDirectorySync.SyncAsync(
+ http,
+ new GitHubSyncRequest
+ {
+ Repo = fallback.SourceRepo,
+ RepoUrl = fallback.SourceRepoUrl,
+ SourcePath = fallback.SourcePath,
+ Ref = fallback.DesiredRef,
+ DestinationRoot = destinationRoot,
+ MetadataFileName = fallback.SyncMetadataFileName,
+ ManifestVersion = manifest.ManifestVersion,
+ DryRun = dryRun
+ },
+ cancellationToken);
+
+ result.OverallStatus = DevFlowInitStatus.Success;
+ result.BootstrapMode = "local-skill-sync";
+ result.FilesChanged.AddRange(syncResult.DownloadedFiles);
+ result.FilesChanged.Add(syncResult.MetadataPath);
+ result.ManualSteps.AddRange(selectedHost.Verify.ManualSteps);
+ return result;
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ result.OverallStatus = DevFlowInitStatus.ManualRequired;
+ result.BootstrapMode = "manual";
+ result.ManualSteps.Add($"Automatic local skill sync for {selectedHost.DisplayName} failed: {ex.Message}");
+ result.ManualSteps.Add("Re-run `maui devflow init` when network access to GitHub is available, or retry with `--ai-host` to target a specific AI host.");
+ result.ManualSteps.Add($"Manually sync the configured AI bootstrap files from `{fallback.SourceRepoUrl}` ({fallback.SourcePath} @ {fallback.DesiredRef}) into `{destinationRoot}`.");
+ result.ManualSteps.AddRange(selectedHost.Verify.ManualSteps);
+ return result;
+ }
+ }
+
+ result.OverallStatus = DevFlowInitStatus.ManualRequired;
+ result.BootstrapMode = "manual";
+ foreach (var install in selectedHost.MarketplaceInstalls)
+ result.ManualSteps.AddRange(install.ManualSteps);
+ result.ManualSteps.AddRange(selectedHost.Verify.ManualSteps);
+ return result;
+ }
+
+ static bool IsHostDetected(DevFlowAiHostManifest host, string workspaceRoot)
+ {
+ return host.Detect.Executables.Any(IsExecutableOnPath) ||
+ host.Detect.RepoMarkers.Any(marker => File.Exists(Path.Combine(workspaceRoot, marker)) || Directory.Exists(Path.Combine(workspaceRoot, marker))) ||
+ host.Detect.ConfigMarkers.Any(marker => File.Exists(Path.Combine(workspaceRoot, marker)) || Directory.Exists(Path.Combine(workspaceRoot, marker)));
+ }
+
+ static DevFlowAiHostManifest? ResolveHost(
+ DevFlowInitManifest manifest,
+ IReadOnlyList detectedHosts,
+ string? explicitHost,
+ bool interactive)
+ {
+ if (!string.IsNullOrWhiteSpace(explicitHost))
+ {
+ return manifest.Hosts.FirstOrDefault(host =>
+ string.Equals(host.Id, explicitHost, StringComparison.OrdinalIgnoreCase));
+ }
+
+ if (detectedHosts.Count == 1)
+ return detectedHosts[0];
+
+ if (detectedHosts.Count > 1 && interactive)
+ {
+ return AnsiConsole.Prompt(
+ new SelectionPrompt()
+ .Title("[bold]Select the AI host to configure[/]")
+ .UseConverter(host => host.DisplayName)
+ .AddChoices(detectedHosts));
+ }
+
+ if (detectedHosts.Count == 0 && interactive)
+ {
+ var localHosts = manifest.Hosts.Where(host => host.RepoLocalFallbacks.Count > 0).ToList();
+ if (localHosts.Count > 0)
+ {
+ return AnsiConsole.Prompt(
+ new SelectionPrompt()
+ .Title("[bold]No AI host was detected. Select a repo-local skill target[/]")
+ .UseConverter(host => host.DisplayName)
+ .AddChoices(localHosts));
+ }
+ }
+
+ return null;
+ }
+
+ static bool IsExecutableOnPath(string executableName)
+ {
+ var path = Environment.GetEnvironmentVariable("PATH");
+ if (string.IsNullOrWhiteSpace(path))
+ return false;
+
+ var pathExtensions = OperatingSystem.IsWindows()
+ ? (Environment.GetEnvironmentVariable("PATHEXT") ?? ".EXE;.CMD;.BAT")
+ .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ : [string.Empty];
+
+ foreach (var directory in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ {
+ foreach (var extension in pathExtensions)
+ {
+ var candidate = Path.Combine(directory, executableName + extension);
+ if (File.Exists(candidate))
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/src/Cli/Microsoft.Maui.Cli/DevFlow/Init/DevFlowInitCommand.cs b/src/Cli/Microsoft.Maui.Cli/DevFlow/Init/DevFlowInitCommand.cs
new file mode 100644
index 000000000..781bde5b6
--- /dev/null
+++ b/src/Cli/Microsoft.Maui.Cli/DevFlow/Init/DevFlowInitCommand.cs
@@ -0,0 +1,626 @@
+using System.Reflection;
+using System.Text;
+using System.Text.Json;
+using Microsoft.Maui.Cli.Commands;
+using Microsoft.Maui.Cli.Errors;
+using Spectre.Console;
+
+namespace Microsoft.Maui.Cli.DevFlow.Init;
+
+internal sealed class DevFlowInitOptions
+{
+ public string? Project { get; init; }
+ public bool All { get; init; }
+ public bool ForceBlazor { get; init; }
+ public bool DisableBlazor { get; init; }
+ public bool ForceGtk { get; init; }
+ public bool Force { get; init; }
+ public string? NewTemplate { get; init; }
+ public string? NewName { get; init; }
+ public bool NoAi { get; init; }
+ public string? AiHost { get; init; }
+ public bool AiLocalOnly { get; init; }
+ public bool Json { get; init; }
+ public bool NoJson { get; init; }
+ public bool DryRun { get; init; }
+ public bool Ci { get; init; }
+}
+
+internal static class DevFlowInitCommand
+{
+ public static async Task ExecuteAsync(DevFlowInitOptions options, IDevFlowOutputWriter output, CancellationToken cancellationToken = default)
+ {
+ var manifest = DevFlowInitManifestLoader.Load();
+
+ string workspaceRoot;
+ try
+ {
+ workspaceRoot = Directory.GetCurrentDirectory();
+ }
+ catch (Exception)
+ {
+ Console.Error.WriteLine("Error: Cannot determine the current directory. " +
+ "If you deleted and recreated the folder, run `cd .` or re-enter the directory to refresh the shell.");
+ return false;
+ }
+
+ var reportPath = Path.Combine(workspaceRoot, "MAUI-DEVFLOW-INIT-REPORT.md");
+ var jsonReportPath = Path.Combine(workspaceRoot, "MAUI-DEVFLOW-INIT-REPORT.json");
+ var json = output.ResolveJsonMode(options.Json, options.NoJson);
+ var interactive = !options.Ci && !json && !Console.IsInputRedirected && !Console.IsOutputRedirected;
+
+ var report = new DevFlowInitReport
+ {
+ WorkspacePath = workspaceRoot,
+ ReportPath = reportPath,
+ JsonReportPath = jsonReportPath,
+ GeneratedAtUtc = DateTime.UtcNow.ToString("o"),
+ CliVersion = typeof(DevFlowCommands).Assembly
+ .GetCustomAttribute()?.InformationalVersion ?? "unknown",
+ ManifestVersion = manifest.ManifestVersion,
+ ExecutionMode = BuildExecutionMode(options, interactive)
+ };
+
+ try
+ {
+ if (!string.IsNullOrWhiteSpace(options.NewTemplate))
+ {
+ var scaffoldResult = await ScaffoldNewProjectAsync(workspaceRoot, options.NewTemplate, options.NewName, options.DryRun, cancellationToken);
+ if (scaffoldResult.Status != DevFlowInitStatus.Success)
+ {
+ report.OverallStatus = scaffoldResult.Status;
+ report.Notes.Add(scaffoldResult.Detail);
+ report.AiBootstrap = new DevFlowAiBootstrapResult
+ {
+ OverallStatus = options.NoAi ? DevFlowInitStatus.Disabled : DevFlowInitStatus.Skipped,
+ BootstrapMode = options.NoAi ? "disabled" : "manual"
+ };
+ await WriteReportAsync(report, options.DryRun, cancellationToken);
+ output.WriteResult(report, json, PrintHumanSummary);
+ return false;
+ }
+ report.Notes.Add($"Scaffolded new project: {scaffoldResult.Detail}");
+ }
+
+ var discovered = DevFlowProjectScanner.Discover(workspaceRoot);
+ if (discovered.Count == 0)
+ {
+ report.OverallStatus = DevFlowInitStatus.ManualRequired;
+ report.Notes.Add("No MAUI projects were found below the current directory.");
+ report.Notes.Add("Create a project with `dotnet new maui` or `dotnet new maui-blazor`, then rerun `maui devflow init`.");
+ report.AiBootstrap = new DevFlowAiBootstrapResult
+ {
+ OverallStatus = options.NoAi ? DevFlowInitStatus.Disabled : DevFlowInitStatus.Skipped,
+ BootstrapMode = options.NoAi ? "disabled" : "manual"
+ };
+
+ await WriteReportAsync(report, options.DryRun, cancellationToken);
+ output.WriteResult(report, json, PrintHumanSummary);
+ return false;
+ }
+
+ var explicitlySelected = ResolveExplicitProjectSelection(workspaceRoot, options.Project);
+ var eligible = discovered.Where(candidate => candidate.IsSupported && (!candidate.IsAlreadyIntegrated || options.Force)).ToList();
+ var selected = ResolveTargets(eligible, explicitlySelected, options.All, interactive);
+
+ foreach (var candidate in discovered.Where(candidate => !candidate.IsSupported))
+ {
+ report.Projects.Add(new DevFlowInitProjectResult
+ {
+ ProjectPath = candidate.ProjectPath,
+ RelativePath = candidate.RelativePath,
+ Flavor = candidate.Flavor,
+ OverallStatus = DevFlowInitStatus.Unsupported,
+ Operations =
+ [
+ new DevFlowInitOperationResult
+ {
+ Name = "Project support",
+ Status = DevFlowInitStatus.ManualRequired,
+ Detail = "This project flavor is not supported by the current init flow."
+ }
+ ],
+ ManualSteps = [$"Add DevFlow to {candidate.RelativePath} manually."]
+ });
+ }
+
+ foreach (var candidate in discovered.Where(candidate => candidate.IsAlreadyIntegrated && !options.Force))
+ {
+ report.Projects.Add(new DevFlowInitProjectResult
+ {
+ ProjectPath = candidate.ProjectPath,
+ RelativePath = candidate.RelativePath,
+ Flavor = candidate.Flavor,
+ OverallStatus = DevFlowInitStatus.AlreadyPresent,
+ Operations =
+ [
+ new DevFlowInitOperationResult
+ {
+ Name = "Existing DevFlow integration",
+ Status = DevFlowInitStatus.AlreadyPresent,
+ Detail = "DevFlow is already integrated in this project."
+ }
+ ],
+ VerificationCommands =
+ [
+ "dotnet build",
+ "maui devflow wait",
+ "maui devflow diagnose"
+ ],
+ ManualSteps = [$"To re-apply and update package versions, re-run with `--force`."]
+ });
+ }
+
+ if (selected.Count == 0 && explicitlySelected == null)
+ {
+ report.OverallStatus = report.Projects.Any(project => project.OverallStatus == DevFlowInitStatus.AlreadyPresent)
+ ? DevFlowInitStatus.AlreadyPresent
+ : DevFlowInitStatus.ManualRequired;
+ report.Notes.Add(report.OverallStatus == DevFlowInitStatus.AlreadyPresent
+ ? "All discovered supported MAUI projects are already onboarded."
+ : "No eligible MAUI projects were selected for onboarding.");
+ }
+
+ foreach (var candidate in selected)
+ {
+ if (report.Projects.Any(p => string.Equals(p.ProjectPath, candidate.ProjectPath, StringComparison.OrdinalIgnoreCase)))
+ continue;
+
+ var effectiveCandidate = ApplyOverrides(candidate, options);
+ report.Projects.Add(DevFlowProjectUpdater.Apply(effectiveCandidate, manifest, options.DryRun, workspaceRoot));
+ }
+
+ report.AiBootstrap = await AiHostBootstrapper.RunAsync(
+ manifest,
+ workspaceRoot,
+ options.AiHost,
+ options.NoAi,
+ options.AiLocalOnly,
+ interactive,
+ options.DryRun,
+ cancellationToken);
+
+ report.OverallStatus = DetermineOverallStatus(report);
+ PopulateNextSteps(report);
+ await WriteReportAsync(report, options.DryRun, cancellationToken);
+ output.WriteResult(report, json, PrintHumanSummary);
+ return report.OverallStatus is DevFlowInitStatus.Success or DevFlowInitStatus.AlreadyPresent;
+ }
+ catch (MauiToolException ex)
+ {
+ report.OverallStatus = DevFlowInitStatus.Failed;
+ report.Notes.Add(ex.Message);
+ if (ex.Remediation?.ManualSteps is { Length: > 0 })
+ report.Notes.AddRange(ex.Remediation.ManualSteps);
+
+ PopulateNextSteps(report);
+ try { await WriteReportAsync(report, options.DryRun, cancellationToken); } catch { /* best-effort */ }
+ output.WriteResult(report, json, PrintHumanSummary);
+ return false;
+ }
+ catch (Exception ex)
+ {
+ report.OverallStatus = DevFlowInitStatus.Failed;
+ report.Notes.Add(ex.Message);
+ PopulateNextSteps(report);
+ try { await WriteReportAsync(report, options.DryRun, cancellationToken); } catch { /* best-effort */ }
+ output.WriteResult(report, json, PrintHumanSummary);
+ return false;
+ }
+ }
+
+ static string BuildExecutionMode(DevFlowInitOptions options, bool interactive)
+ {
+ var mode = new List();
+ mode.Add(interactive ? "interactive" : "non-interactive");
+ if (!string.IsNullOrWhiteSpace(options.Project))
+ mode.Add("--project");
+ if (options.All)
+ mode.Add("--all");
+ if (options.Force)
+ mode.Add("--force");
+ if (options.ForceGtk)
+ mode.Add("--gtk");
+ if (!string.IsNullOrWhiteSpace(options.NewTemplate))
+ mode.Add($"--new {options.NewTemplate}");
+ if (options.DryRun)
+ mode.Add("--dry-run");
+ return string.Join(", ", mode);
+ }
+
+ static DevFlowProjectCandidate? ResolveExplicitProjectSelection(string workspaceRoot, string? projectOrDirectory)
+ {
+ if (string.IsNullOrWhiteSpace(projectOrDirectory))
+ return null;
+
+ var resolved = MauiProjectResolver.Resolve(projectOrDirectory);
+ var candidate = DevFlowProjectScanner.DescribeProject(workspaceRoot, resolved.ProjectPath);
+ if (candidate == null)
+ {
+ throw MauiToolException.UserActionRequired(
+ ErrorCodes.InvalidArgument,
+ $"'{resolved.ProjectPath}' is not a supported MAUI project for `maui devflow init`.",
+ [$"Select a standard MAUI app project or run init from a workspace that contains one."]);
+ }
+
+ return candidate;
+ }
+
+ static List ResolveTargets(
+ IReadOnlyList eligible,
+ DevFlowProjectCandidate? explicitProject,
+ bool all,
+ bool interactive)
+ {
+ if (explicitProject != null)
+ return [explicitProject];
+
+ if (all)
+ return eligible.ToList();
+
+ if (eligible.Count == 1)
+ return [eligible[0]];
+
+ if (eligible.Count == 0)
+ return [];
+
+ if (!interactive)
+ {
+ throw MauiToolException.UserActionRequired(
+ ErrorCodes.InvalidArgument,
+ "Multiple eligible MAUI projects were found.",
+ ["Re-run with `--project ` or `--all`."]);
+ }
+
+ var selection = AnsiConsole.Prompt(
+ new MultiSelectionPrompt()
+ .Title("[bold]Select the MAUI project(s) to onboard[/]")
+ .NotRequired()
+ .UseConverter(candidate => $"{candidate.RelativePath} [grey]({candidate.Flavor})[/]")
+ .AddChoices(eligible));
+
+ return selection.ToList();
+ }
+
+ static DevFlowProjectCandidate ApplyOverrides(DevFlowProjectCandidate candidate, DevFlowInitOptions options)
+ {
+ var needsBlazor = candidate.NeedsBlazor;
+ if (options.ForceBlazor)
+ needsBlazor = true;
+ if (options.DisableBlazor)
+ needsBlazor = false;
+
+ var isGtk = candidate.Flavor.StartsWith("gtk", StringComparison.OrdinalIgnoreCase) || options.ForceGtk;
+ var flavor = isGtk
+ ? (needsBlazor ? "gtk-blazor" : "gtk")
+ : needsBlazor
+ ? "standard-maui-blazor"
+ : candidate.Flavor;
+
+ return new DevFlowProjectCandidate
+ {
+ ProjectPath = candidate.ProjectPath,
+ RelativePath = candidate.RelativePath,
+ Flavor = flavor,
+ IsSupported = candidate.IsSupported,
+ NeedsBlazor = needsBlazor,
+ IsAlreadyIntegrated = candidate.IsAlreadyIntegrated,
+ MauiProgramPath = candidate.MauiProgramPath
+ };
+ }
+
+ static string DetermineOverallStatus(DevFlowInitReport report)
+ {
+ var statuses = report.Projects.Select(project => project.OverallStatus).ToList();
+
+ // Project-level failures take priority
+ if (statuses.Contains(DevFlowInitStatus.Failed, StringComparer.Ordinal))
+ return DevFlowInitStatus.Failed;
+ if (statuses.Contains(DevFlowInitStatus.ManualRequired, StringComparer.Ordinal) ||
+ statuses.Contains(DevFlowInitStatus.Unsupported, StringComparer.Ordinal))
+ return DevFlowInitStatus.ManualRequired;
+
+ if (statuses.Count == 0 && report.AiBootstrap.OverallStatus == DevFlowInitStatus.Disabled)
+ return DevFlowInitStatus.Skipped;
+
+ // AI bootstrap issues are surfaced in next steps, not the overall status —
+ // project onboarding success should not be downgraded by AI sync failures.
+ if (statuses.Count > 0 && statuses.All(status => status == DevFlowInitStatus.AlreadyPresent))
+ return DevFlowInitStatus.AlreadyPresent;
+
+ return DevFlowInitStatus.Success;
+ }
+
+ static void PopulateNextSteps(DevFlowInitReport report)
+ {
+ // Per-project verification commands for successfully onboarded projects
+ foreach (var project in report.Projects)
+ {
+ if (project.VerificationCommands.Count > 0)
+ continue; // Already populated (e.g. already-onboarded)
+
+ if (project.OverallStatus is DevFlowInitStatus.Success)
+ {
+ project.VerificationCommands.Add($"dotnet build {project.RelativePath}");
+ project.VerificationCommands.Add("maui devflow wait");
+ project.VerificationCommands.Add("maui devflow tree");
+ }
+ else if (project.OverallStatus is DevFlowInitStatus.ManualRequired or DevFlowInitStatus.Unsupported)
+ {
+ project.VerificationCommands.Add($"# Review manual steps above, then:");
+ project.VerificationCommands.Add($"dotnet build {project.RelativePath}");
+ }
+ }
+
+ // Top-level next steps based on overall outcome
+ if (report.OverallStatus is DevFlowInitStatus.Success)
+ {
+ report.NextSteps.Add("Build and run your MAUI app with DevFlow enabled.");
+ report.NextSteps.Add("Run `maui devflow wait` to connect DevFlow to your running app.");
+ report.NextSteps.Add("Use `maui devflow tree` to inspect the visual tree.");
+ report.NextSteps.Add("Use `maui devflow diagnose` if connection issues occur.");
+ }
+ else if (report.OverallStatus is DevFlowInitStatus.AlreadyPresent)
+ {
+ report.NextSteps.Add("DevFlow is already integrated in this workspace.");
+ report.NextSteps.Add("Run `maui devflow wait` to connect to your running app.");
+ report.NextSteps.Add("To update package versions, re-run `maui devflow init --force`.");
+ }
+ else if (report.OverallStatus is DevFlowInitStatus.ManualRequired)
+ {
+ report.NextSteps.Add("Some steps require manual intervention — see project details above.");
+ report.NextSteps.Add("After manual fixes, run `dotnet build` to verify.");
+ report.NextSteps.Add("Then run `maui devflow wait` to verify the agent starts.");
+ }
+ else if (report.OverallStatus is DevFlowInitStatus.Failed)
+ {
+ report.NextSteps.Add("Init failed — review the errors in the report above.");
+ report.NextSteps.Add("Re-run `maui devflow init` after addressing the issues.");
+ }
+
+ // AI-related next steps
+ if (report.AiBootstrap.OverallStatus == DevFlowInitStatus.ManualRequired)
+ {
+ report.NextSteps.Add("AI bootstrap requires manual setup — see the AI bootstrap section.");
+ }
+ }
+
+ static async Task WriteReportAsync(DevFlowInitReport report, bool dryRun = false, CancellationToken cancellationToken = default)
+ {
+ if (dryRun)
+ return;
+
+ var markdown = BuildMarkdown(report);
+ await File.WriteAllTextAsync(report.ReportPath, markdown, cancellationToken);
+
+ var jsonBytes = JsonSerializer.SerializeToUtf8Bytes(report, DevFlowInitReportJsonContext.Default.DevFlowInitReport);
+ await File.WriteAllBytesAsync(report.JsonReportPath, jsonBytes, cancellationToken);
+ }
+
+ static string BuildMarkdown(DevFlowInitReport report)
+ {
+ var builder = new StringBuilder();
+ builder.AppendLine("# MAUI DEVFLOW INIT REPORT");
+ builder.AppendLine();
+ builder.AppendLine($"- **Generated:** {report.GeneratedAtUtc}");
+ builder.AppendLine($"- **Workspace:** `{report.WorkspacePath}`");
+ builder.AppendLine($"- **CLI version:** `{report.CliVersion}`");
+ builder.AppendLine($"- **Manifest version:** `{report.ManifestVersion}`");
+ builder.AppendLine($"- **Execution mode:** `{report.ExecutionMode}`");
+ builder.AppendLine($"- **Overall status:** `{report.OverallStatus}`");
+ builder.AppendLine();
+ builder.AppendLine("## AI bootstrap");
+ builder.AppendLine();
+ builder.AppendLine($"- **Status:** `{report.AiBootstrap.OverallStatus}`");
+ builder.AppendLine($"- **Detected hosts:** {(report.AiBootstrap.DetectedHosts.Count == 0 ? "_none_" : string.Join(", ", report.AiBootstrap.DetectedHosts))}");
+ builder.AppendLine($"- **Selected host:** {(string.IsNullOrWhiteSpace(report.AiBootstrap.SelectedHostDisplayName) ? "_none_" : report.AiBootstrap.SelectedHostDisplayName)}");
+ builder.AppendLine($"- **Bootstrap mode:** `{report.AiBootstrap.BootstrapMode}`");
+ if (report.AiBootstrap.FilesChanged.Count > 0)
+ {
+ builder.AppendLine("- **Files changed:**");
+ foreach (var file in report.AiBootstrap.FilesChanged.Distinct(StringComparer.OrdinalIgnoreCase))
+ builder.AppendLine($" - `{Path.GetRelativePath(report.WorkspacePath, file)}`");
+ }
+ if (report.AiBootstrap.ManualSteps.Count > 0)
+ {
+ builder.AppendLine("- **Manual steps:**");
+ foreach (var step in report.AiBootstrap.ManualSteps)
+ builder.AppendLine($" - {step}");
+ }
+
+ foreach (var project in report.Projects.OrderBy(project => project.RelativePath, StringComparer.OrdinalIgnoreCase))
+ {
+ builder.AppendLine();
+ builder.AppendLine($"## Project: `{project.RelativePath}`");
+ builder.AppendLine();
+ builder.AppendLine($"- **Flavor:** `{project.Flavor}`");
+ builder.AppendLine($"- **Status:** `{project.OverallStatus}`");
+ if (project.FilesChanged.Count > 0)
+ {
+ builder.AppendLine("- **Files changed:**");
+ foreach (var file in project.FilesChanged.Distinct(StringComparer.OrdinalIgnoreCase))
+ builder.AppendLine($" - `{Path.GetRelativePath(report.WorkspacePath, file)}`");
+ }
+ builder.AppendLine();
+ builder.AppendLine("| Operation | Status | Detail |");
+ builder.AppendLine("|---|---|---|");
+ foreach (var operation in project.Operations)
+ builder.AppendLine($"| {EscapePipe(operation.Name)} | `{operation.Status}` | {EscapePipe(operation.Detail)} |");
+ if (project.ManualSteps.Count > 0)
+ {
+ builder.AppendLine();
+ builder.AppendLine("### Manual steps");
+ builder.AppendLine();
+ foreach (var step in project.ManualSteps)
+ AppendListItem(builder, step);
+ }
+ if (project.VerificationCommands.Count > 0)
+ {
+ builder.AppendLine();
+ builder.AppendLine("### Verification");
+ builder.AppendLine();
+ builder.AppendLine("```bash");
+ foreach (var cmd in project.VerificationCommands)
+ builder.AppendLine(cmd);
+ builder.AppendLine("```");
+ }
+ }
+
+ if (report.Notes.Count > 0)
+ {
+ builder.AppendLine();
+ builder.AppendLine("## Notes");
+ builder.AppendLine();
+ foreach (var note in report.Notes)
+ AppendListItem(builder, note);
+ }
+
+ if (report.NextSteps.Count > 0)
+ {
+ builder.AppendLine();
+ builder.AppendLine("## Next steps");
+ builder.AppendLine();
+ foreach (var step in report.NextSteps)
+ AppendListItem(builder, step);
+ }
+
+ return builder.ToString();
+ }
+
+ static string EscapePipe(string value) => value.Replace("|", "\\|", StringComparison.Ordinal);
+
+ ///
+ /// Appends a markdown list item, properly indenting continuation lines so that
+ /// multi-line content (including fenced code blocks) renders correctly inside the list.
+ ///
+ static void AppendListItem(StringBuilder builder, string step)
+ {
+ var lines = step.Replace("\r\n", "\n", StringComparison.Ordinal)
+ .Replace('\r', '\n')
+ .Split('\n');
+
+ if (lines.Length == 0)
+ {
+ builder.AppendLine("-");
+ return;
+ }
+
+ builder.AppendLine($"- {lines[0]}");
+ for (var i = 1; i < lines.Length; i++)
+ builder.AppendLine(lines[i].Length == 0 ? string.Empty : $" {lines[i]}");
+ }
+
+ static void PrintHumanSummary(DevFlowInitReport report)
+ {
+ Console.WriteLine($"DevFlow init status: {report.OverallStatus}");
+ Console.WriteLine($"Report: {report.ReportPath}");
+ Console.WriteLine($"JSON report: {report.JsonReportPath}");
+ Console.WriteLine($"Projects: {report.Projects.Count}");
+ if (!string.IsNullOrWhiteSpace(report.AiBootstrap.SelectedHostDisplayName))
+ Console.WriteLine($"AI host: {report.AiBootstrap.SelectedHostDisplayName} ({report.AiBootstrap.BootstrapMode})");
+ if (report.Notes.Count > 0)
+ {
+ Console.WriteLine();
+ foreach (var note in report.Notes)
+ Console.WriteLine($"- {note}");
+ }
+ if (report.NextSteps.Count > 0)
+ {
+ Console.WriteLine();
+ Console.WriteLine("Next steps:");
+ foreach (var step in report.NextSteps)
+ Console.WriteLine($" - {step}");
+ }
+ }
+
+ static readonly HashSet s_validTemplates = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "maui", "maui-blazor"
+ };
+
+ static async Task<(string Status, string Detail)> ScaffoldNewProjectAsync(
+ string workspaceRoot,
+ string template,
+ string? name,
+ bool dryRun,
+ CancellationToken cancellationToken = default)
+ {
+ if (!s_validTemplates.Contains(template))
+ {
+ return (DevFlowInitStatus.Failed,
+ $"Unknown template '{template}'. Supported templates: {string.Join(", ", s_validTemplates)}.");
+ }
+
+ var projectName = name ?? "MauiApp1";
+
+ // Validate project name to prevent path traversal and argument injection
+ if (!System.Text.RegularExpressions.Regex.IsMatch(projectName, @"^[A-Za-z0-9._-]+$"))
+ return (DevFlowInitStatus.Failed, $"Invalid project name: '{projectName}'. Use only letters, digits, dots, hyphens, or underscores.");
+
+ var outputDir = Path.GetFullPath(Path.Combine(workspaceRoot, projectName));
+ if (!outputDir.StartsWith(Path.GetFullPath(workspaceRoot) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
+ return (DevFlowInitStatus.Failed, "Project name would escape workspace root.");
+
+ if (Directory.Exists(outputDir) && Directory.EnumerateFileSystemEntries(outputDir).Any())
+ {
+ return (DevFlowInitStatus.Failed,
+ $"Directory '{projectName}' already exists and is not empty.");
+ }
+
+ if (dryRun)
+ {
+ return (DevFlowInitStatus.Success,
+ $"Would create '{template}' project named '{projectName}' at {outputDir}.");
+ }
+
+ var psi = new System.Diagnostics.ProcessStartInfo("dotnet")
+ {
+ WorkingDirectory = workspaceRoot,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false
+ };
+ psi.ArgumentList.Add("new");
+ psi.ArgumentList.Add(template);
+ psi.ArgumentList.Add("-n");
+ psi.ArgumentList.Add(projectName);
+ psi.ArgumentList.Add("-o");
+ psi.ArgumentList.Add(outputDir);
+
+ var argsDisplay = $"new {template} -n {projectName} -o \"{outputDir}\"";
+ try
+ {
+ var process = System.Diagnostics.Process.Start(psi);
+
+ if (process == null)
+ {
+ return (DevFlowInitStatus.Failed,
+ $"Failed to start `dotnet {argsDisplay}`.");
+ }
+
+ // Read stdout and stderr concurrently to avoid deadlock when OS pipe
+ // buffers fill (the process blocks writing to one while we sequentially
+ // drain the other).
+ var stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
+ var stderrTask = process.StandardError.ReadToEndAsync(cancellationToken);
+ await process.WaitForExitAsync(cancellationToken);
+ var stdout = await stdoutTask;
+ var stderr = await stderrTask;
+
+ if (process.ExitCode != 0)
+ {
+ var detail = !string.IsNullOrWhiteSpace(stderr) ? stderr.Trim() : stdout.Trim();
+ return (DevFlowInitStatus.Failed,
+ $"`dotnet {argsDisplay}` exited with code {process.ExitCode}: {detail}");
+ }
+
+ return (DevFlowInitStatus.Success,
+ $"Created '{template}' project '{projectName}' at {outputDir}.");
+ }
+ catch (Exception ex)
+ {
+ return (DevFlowInitStatus.Failed,
+ $"Failed to scaffold project: {ex.Message}");
+ }
+ }
+}
diff --git a/src/Cli/Microsoft.Maui.Cli/DevFlow/Init/DevFlowInitManifest.cs b/src/Cli/Microsoft.Maui.Cli/DevFlow/Init/DevFlowInitManifest.cs
new file mode 100644
index 000000000..e4af56bed
--- /dev/null
+++ b/src/Cli/Microsoft.Maui.Cli/DevFlow/Init/DevFlowInitManifest.cs
@@ -0,0 +1,106 @@
+using System.Reflection;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Microsoft.Maui.Cli.DevFlow.Init;
+
+internal sealed class DevFlowInitManifest
+{
+ public int SchemaVersion { get; set; }
+ public string ManifestVersion { get; set; } = "";
+ public DevFlowInitPackageSet Packages { get; set; } = new();
+ public List Hosts { get; set; } = [];
+}
+
+internal sealed class DevFlowInitPackageSet
+{
+ public DevFlowNuGetPackageManifest Agent { get; set; } = new();
+ public DevFlowNuGetPackageManifest Blazor { get; set; } = new();
+ public DevFlowNuGetPackageManifest AgentGtk { get; set; } = new();
+ public DevFlowNuGetPackageManifest BlazorGtk { get; set; } = new();
+}
+
+internal sealed class DevFlowNuGetPackageManifest
+{
+ public string PackageId { get; set; } = "";
+ public string Version { get; set; } = "";
+}
+
+internal sealed class DevFlowAiHostManifest
+{
+ public string Id { get; set; } = "";
+ public string DisplayName { get; set; } = "";
+ public DevFlowAiHostDetectionManifest Detect { get; set; } = new();
+ public List MarketplaceInstalls { get; set; } = [];
+ public List RepoLocalFallbacks { get; set; } = [];
+ public DevFlowAiHostVerifyManifest Verify { get; set; } = new();
+}
+
+internal sealed class DevFlowAiHostDetectionManifest
+{
+ public List Executables { get; set; } = [];
+ public List RepoMarkers { get; set; } = [];
+ public List ConfigMarkers { get; set; } = [];
+}
+
+internal sealed class DevFlowMarketplaceInstallManifest
+{
+ public string MarketplaceId { get; set; } = "";
+ public string PluginId { get; set; } = "";
+ public string DesiredVersion { get; set; } = "";
+ public string InstallStrategy { get; set; } = "";
+ public string UpdatePolicy { get; set; } = "";
+ public List ManualSteps { get; set; } = [];
+}
+
+internal sealed class DevFlowRepoLocalFallbackManifest
+{
+ public string SourceRepo { get; set; } = "";
+ public string SourceRepoUrl { get; set; } = "";
+ public string SourcePath { get; set; } = "";
+ public string DesiredRef { get; set; } = "";
+ public string TargetPathTemplate { get; set; } = "";
+ public string SyncMetadataFileName { get; set; } = ".skill-version";
+}
+
+internal sealed class DevFlowAiHostVerifyManifest
+{
+ public List ManualSteps { get; set; } = [];
+}
+
+internal static class DevFlowInitManifestLoader
+{
+ static readonly Lazy s_manifest = new(LoadCore);
+
+ public static DevFlowInitManifest Load() => s_manifest.Value;
+
+ static DevFlowInitManifest LoadCore()
+ {
+ var assembly = typeof(DevFlowInitManifestLoader).Assembly;
+ var resourceName = assembly
+ .GetManifestResourceNames()
+ .FirstOrDefault(name => name.EndsWith("devflow-init-manifest.json", StringComparison.OrdinalIgnoreCase));
+
+ if (resourceName == null)
+ throw new InvalidOperationException("Could not find embedded DevFlow init manifest.");
+
+ using var stream = assembly.GetManifestResourceStream(resourceName)
+ ?? throw new InvalidOperationException($"Could not open embedded manifest resource '{resourceName}'.");
+ using var reader = new StreamReader(stream);
+ var json = reader.ReadToEnd();
+
+ var manifest = JsonSerializer.Deserialize(
+ json,
+ DevFlowInitManifestJsonContext.Default.DevFlowInitManifest);
+
+ return manifest ?? throw new InvalidOperationException("Could not deserialize DevFlow init manifest.");
+ }
+}
+
+[JsonSourceGenerationOptions(
+ PropertyNameCaseInsensitive = true,
+ ReadCommentHandling = JsonCommentHandling.Skip)]
+[JsonSerializable(typeof(DevFlowInitManifest))]
+internal sealed partial class DevFlowInitManifestJsonContext : JsonSerializerContext
+{
+}
diff --git a/src/Cli/Microsoft.Maui.Cli/DevFlow/Init/DevFlowInitModels.cs b/src/Cli/Microsoft.Maui.Cli/DevFlow/Init/DevFlowInitModels.cs
new file mode 100644
index 000000000..dd8674712
--- /dev/null
+++ b/src/Cli/Microsoft.Maui.Cli/DevFlow/Init/DevFlowInitModels.cs
@@ -0,0 +1,80 @@
+namespace Microsoft.Maui.Cli.DevFlow.Init;
+
+using System.Text.Json.Serialization;
+
+internal static class DevFlowInitStatus
+{
+ public const string Success = "success";
+ public const string AlreadyPresent = "already_present";
+ public const string Skipped = "skipped";
+ public const string ManualRequired = "manual_required";
+ public const string Failed = "failed";
+ public const string Unsupported = "unsupported";
+ public const string Disabled = "disabled";
+}
+
+internal sealed class DevFlowProjectCandidate
+{
+ public string ProjectPath { get; init; } = "";
+ public string RelativePath { get; init; } = "";
+ public string Flavor { get; init; } = "";
+ public bool IsSupported { get; init; }
+ public bool NeedsBlazor { get; init; }
+ public bool IsAlreadyIntegrated { get; init; }
+ public string? MauiProgramPath { get; init; }
+}
+
+internal sealed class DevFlowInitOperationResult
+{
+ public string Name { get; init; } = "";
+ public string Status { get; init; } = DevFlowInitStatus.Skipped;
+ public string Detail { get; init; } = "";
+ public List FilesChanged { get; init; } = [];
+ public List ManualSteps { get; init; } = [];
+}
+
+internal sealed class DevFlowInitProjectResult
+{
+ public string ProjectPath { get; init; } = "";
+ public string RelativePath { get; init; } = "";
+ public string Flavor { get; init; } = "";
+ public string OverallStatus { get; set; } = DevFlowInitStatus.Skipped;
+ public List Operations { get; init; } = [];
+ public List FilesChanged { get; init; } = [];
+ public List ManualSteps { get; init; } = [];
+ public List VerificationCommands { get; init; } = [];
+}
+
+internal sealed class DevFlowAiBootstrapResult
+{
+ public string OverallStatus { get; set; } = DevFlowInitStatus.Disabled;
+ public List DetectedHosts { get; init; } = [];
+ public string? SelectedHostId { get; set; }
+ public string? SelectedHostDisplayName { get; set; }
+ public string BootstrapMode { get; set; } = "disabled";
+ public List FilesChanged { get; init; } = [];
+ public List ManualSteps { get; init; } = [];
+}
+
+internal sealed class DevFlowInitReport
+{
+ public string WorkspacePath { get; set; } = "";
+ public string ReportPath { get; set; } = "";
+ public string JsonReportPath { get; set; } = "";
+ public string GeneratedAtUtc { get; set; } = "";
+ public string CliVersion { get; set; } = "";
+ public string ManifestVersion { get; set; } = "";
+ public string ExecutionMode { get; set; } = "";
+ public string OverallStatus { get; set; } = DevFlowInitStatus.Skipped;
+ public DevFlowAiBootstrapResult AiBootstrap { get; set; } = new();
+ public List Projects { get; init; } = [];
+ public List Notes { get; init; } = [];
+ public List NextSteps { get; init; } = [];
+}
+
+[JsonSourceGenerationOptions(
+ PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
+ WriteIndented = true,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
+[JsonSerializable(typeof(DevFlowInitReport))]
+internal sealed partial class DevFlowInitReportJsonContext : JsonSerializerContext;
diff --git a/src/Cli/Microsoft.Maui.Cli/DevFlow/Init/DevFlowProjectScanner.cs b/src/Cli/Microsoft.Maui.Cli/DevFlow/Init/DevFlowProjectScanner.cs
new file mode 100644
index 000000000..ed17fb6ee
--- /dev/null
+++ b/src/Cli/Microsoft.Maui.Cli/DevFlow/Init/DevFlowProjectScanner.cs
@@ -0,0 +1,257 @@
+using System.Xml.Linq;
+using Microsoft.Maui.Cli.Commands;
+
+namespace Microsoft.Maui.Cli.DevFlow.Init;
+
+internal static class DevFlowProjectScanner
+{
+ static readonly HashSet s_excludedDirectories = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "bin",
+ "obj",
+ ".git",
+ ".vs",
+ ".idea",
+ "node_modules",
+ "packages"
+ };
+
+ public static IReadOnlyList Discover(string workspaceRoot)
+ {
+ var results = new List();
+ var pending = new Queue();
+ pending.Enqueue(workspaceRoot);
+
+ while (pending.Count > 0)
+ {
+ var directory = pending.Dequeue();
+
+ try
+ {
+ foreach (var projectPath in Directory.EnumerateFiles(directory, "*.csproj", SearchOption.TopDirectoryOnly))
+ {
+ var candidate = CreateCandidate(workspaceRoot, projectPath);
+ if (candidate != null)
+ results.Add(candidate);
+ }
+ }
+ catch
+ {
+ // Best effort scan.
+ }
+
+ try
+ {
+ foreach (var subdirectory in Directory.EnumerateDirectories(directory, "*", SearchOption.TopDirectoryOnly))
+ {
+ var name = Path.GetFileName(subdirectory);
+ if (!string.IsNullOrEmpty(name) && !s_excludedDirectories.Contains(name))
+ pending.Enqueue(subdirectory);
+ }
+ }
+ catch
+ {
+ // Best effort scan.
+ }
+ }
+
+ return results
+ .OrderBy(candidate => candidate.RelativePath, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+
+ public static DevFlowProjectCandidate? DescribeProject(string workspaceRoot, string projectPath)
+ => CreateCandidate(workspaceRoot, projectPath);
+
+ static DevFlowProjectCandidate? CreateCandidate(string workspaceRoot, string projectPath)
+ {
+ // Try dotnet msbuild evaluation first — it fully resolves properties and package
+ // references from Directory.Build.props, Directory.Packages.props, imported
+ // .props/.targets, and composed values. Fall back to raw XML if dotnet CLI
+ // is unavailable or evaluation fails.
+ ProjectView? view = null;
+ var props = DotnetCliProjectReader.GetProperties(projectPath, "UseMaui", "TargetFramework", "TargetFrameworks");
+ if (props.Count > 0)
+ {
+ var cliTfms = DotnetCliProjectReader.GetTargetFrameworks(projectPath);
+ var pkgIds = DotnetCliProjectReader.GetPackageReferenceIds(projectPath);
+ view = ProjectView.FromDotnetCli(props, cliTfms, pkgIds);
+ }
+ else
+ {
+ view = ProjectView.FromXml(projectPath);
+ if (view == null)
+ return null;
+ }
+
+ var hasUseMaui = view.GetBooleanProperty("UseMaui");
+ var tfms = view.TargetFrameworks;
+ var isGtk =
+ view.HasPackageReference("Maui.Gtk") ||
+ view.HasPackageReference("Platform.Maui.Linux.Gtk4") ||
+ view.HasPackageReference("GirCore.Gtk-4.0") ||
+ view.HasPackageReference("Platform.Maui.Linux.Gtk4.BlazorWebView");
+
+ var hasKnownMauiTfm = tfms.Any(tfm =>
+ tfm.Contains("-android", StringComparison.OrdinalIgnoreCase) ||
+ tfm.Contains("-ios", StringComparison.OrdinalIgnoreCase) ||
+ tfm.Contains("-maccatalyst", StringComparison.OrdinalIgnoreCase) ||
+ tfm.Contains("-windows", StringComparison.OrdinalIgnoreCase));
+
+ var isMaui = hasUseMaui || hasKnownMauiTfm || isGtk;
+ if (!isMaui)
+ return null;
+
+ var mauiProgramPath = FindMauiProgramPath(projectPath);
+ var mauiProgramText = mauiProgramPath != null && File.Exists(mauiProgramPath)
+ ? File.ReadAllText(mauiProgramPath)
+ : null;
+
+ var needsBlazor =
+ view.HasPackageReference("Microsoft.AspNetCore.Components.WebView.Maui") ||
+ (mauiProgramText?.Contains("AddMauiBlazorWebView", StringComparison.Ordinal) ?? false);
+
+ var hasAgentPackage =
+ view.HasPackageReference("Microsoft.Maui.DevFlow.Agent") ||
+ view.HasPackageReference("Microsoft.Maui.DevFlow.Agent.Gtk");
+ var hasAgentRegistration =
+ (mauiProgramText?.Contains("AddMauiDevFlowAgent", StringComparison.Ordinal) ?? false);
+ var hasBlazorPackage =
+ view.HasPackageReference("Microsoft.Maui.DevFlow.Blazor") ||
+ view.HasPackageReference("Microsoft.Maui.DevFlow.Blazor.Gtk");
+ var hasBlazorRegistration =
+ (mauiProgramText?.Contains("AddMauiBlazorDevFlowTools", StringComparison.Ordinal) ?? false);
+
+ var fullyIntegrated = hasAgentPackage && hasAgentRegistration && (!needsBlazor || (hasBlazorPackage && hasBlazorRegistration));
+ var flavor = isGtk
+ ? (needsBlazor ? "gtk-blazor" : "gtk")
+ : needsBlazor
+ ? "standard-maui-blazor"
+ : "standard-maui";
+
+ return new DevFlowProjectCandidate
+ {
+ ProjectPath = Path.GetFullPath(projectPath),
+ RelativePath = Path.GetRelativePath(workspaceRoot, projectPath),
+ Flavor = flavor,
+ IsSupported = true,
+ NeedsBlazor = needsBlazor,
+ IsAlreadyIntegrated = fullyIntegrated,
+ MauiProgramPath = mauiProgramPath
+ };
+ }
+
+ static string? FindMauiProgramPath(string projectPath)
+ {
+ var projectDirectory = Path.GetDirectoryName(projectPath);
+ if (projectDirectory == null)
+ return null;
+
+ var mauiProgramPath = Path.Combine(projectDirectory, "MauiProgram.cs");
+ return File.Exists(mauiProgramPath) ? mauiProgramPath : null;
+ }
+
+ ///
+ /// Unified read-only view over either an MSBuild-evaluated project or raw XML fallback.
+ /// MSBuild evaluation correctly resolves properties and package references coming from
+ /// Directory.Build.props, Directory.Packages.props, and other imported .props/.targets.
+ ///
+ sealed class ProjectView
+ {
+ readonly Func _getBooleanProperty;
+ readonly Func _hasPackageReference;
+
+ public IReadOnlyList TargetFrameworks { get; }
+
+ ProjectView(
+ IReadOnlyList targetFrameworks,
+ Func getBooleanProperty,
+ Func hasPackageReference)
+ {
+ TargetFrameworks = targetFrameworks;
+ _getBooleanProperty = getBooleanProperty;
+ _hasPackageReference = hasPackageReference;
+ }
+
+ public bool GetBooleanProperty(string name) => _getBooleanProperty(name);
+ public bool HasPackageReference(string packageId) => _hasPackageReference(packageId);
+
+ public static ProjectView FromDotnetCli(
+ Dictionary properties,
+ IReadOnlyList targetFrameworks,
+ HashSet packageReferenceIds)
+ {
+ return new ProjectView(
+ targetFrameworks,
+ name => properties.TryGetValue(name, out var v) &&
+ string.Equals(v, "true", StringComparison.OrdinalIgnoreCase),
+ packageId => packageReferenceIds.Contains(packageId));
+ }
+
+ public static ProjectView? FromXml(string projectPath)
+ {
+ XDocument document;
+ try
+ {
+ document = XDocument.Load(projectPath);
+ }
+ catch
+ {
+ return null;
+ }
+
+ var tfms = TryGetTargetFrameworksFromProcess(projectPath);
+ if (tfms.Count == 0)
+ tfms = ReadTfmsFromXml(document);
+
+ var packageIds = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var element in document.Descendants()
+ .Where(element => element.Name.LocalName.Equals("PackageReference", StringComparison.OrdinalIgnoreCase)))
+ {
+ var include = element.Attribute("Include")?.Value ?? element.Attribute("Update")?.Value;
+ if (!string.IsNullOrWhiteSpace(include))
+ packageIds.Add(include);
+ }
+
+ return new ProjectView(
+ tfms,
+ name => HasProperty(document, name, "true"),
+ packageId => packageIds.Contains(packageId));
+ }
+
+ static IReadOnlyList TryGetTargetFrameworksFromProcess(string projectPath)
+ {
+ try
+ {
+ return MauiProjectResolver.GetTargetFrameworks(projectPath);
+ }
+ catch
+ {
+ return [];
+ }
+ }
+
+ static IReadOnlyList ReadTfmsFromXml(XDocument document)
+ {
+ var tfms = new List();
+ foreach (var element in document.Descendants())
+ {
+ if (element.Name.LocalName.Equals("TargetFramework", StringComparison.OrdinalIgnoreCase) ||
+ element.Name.LocalName.Equals("TargetFrameworks", StringComparison.OrdinalIgnoreCase))
+ {
+ foreach (var entry in element.Value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ tfms.Add(entry);
+ }
+ }
+ return tfms;
+ }
+
+ static bool HasProperty(XDocument document, string propertyName, string expectedValue)
+ {
+ return document.Descendants()
+ .Any(element =>
+ element.Name.LocalName.Equals(propertyName, StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(element.Value.Trim(), expectedValue, StringComparison.OrdinalIgnoreCase));
+ }
+ }
+}
diff --git a/src/Cli/Microsoft.Maui.Cli/DevFlow/Init/DevFlowProjectUpdater.cs b/src/Cli/Microsoft.Maui.Cli/DevFlow/Init/DevFlowProjectUpdater.cs
new file mode 100644
index 000000000..f76cfaaf3
--- /dev/null
+++ b/src/Cli/Microsoft.Maui.Cli/DevFlow/Init/DevFlowProjectUpdater.cs
@@ -0,0 +1,305 @@
+namespace Microsoft.Maui.Cli.DevFlow.Init;
+
+internal static class DevFlowProjectUpdater
+{
+ public static DevFlowInitProjectResult Apply(DevFlowProjectCandidate candidate, DevFlowInitManifest manifest, bool dryRun, string? workspaceRoot = null)
+ {
+ var result = new DevFlowInitProjectResult
+ {
+ ProjectPath = candidate.ProjectPath,
+ RelativePath = candidate.RelativePath,
+ Flavor = candidate.Flavor
+ };
+
+ if (!candidate.IsSupported)
+ {
+ result.Operations.Add(new DevFlowInitOperationResult
+ {
+ Name = "Project support",
+ Status = DevFlowInitStatus.ManualRequired,
+ Detail = "This project flavor is not supported by the current init implementation.",
+ ManualSteps =
+ [
+ $"Add DevFlow to {candidate.RelativePath} manually:",
+ "1. Add a PackageReference to `Microsoft.Maui.DevFlow.Agent` in the .csproj",
+ "2. In MauiProgram.cs, add `using Microsoft.Maui.DevFlow.Agent;`",
+ "3. After `var builder = MauiApp.CreateBuilder();`, add `#if DEBUG\\nbuilder.AddMauiDevFlowAgent();\\n#endif`"
+ ]
+ });
+ result.ManualSteps.AddRange(result.Operations[^1].ManualSteps);
+ result.OverallStatus = DevFlowInitStatus.ManualRequired;
+ return result;
+ }
+
+ var isGtk = candidate.Flavor.StartsWith("gtk", StringComparison.OrdinalIgnoreCase);
+ var usesCpm = DetectCentralPackageManagement(candidate.ProjectPath, workspaceRoot ?? Path.GetDirectoryName(candidate.ProjectPath)!, out var directoryPackagesPropsPath);
+
+ var agentPkg = isGtk ? manifest.Packages.AgentGtk : manifest.Packages.Agent;
+ var agentPackage = EnsurePackageReference(
+ candidate.ProjectPath,
+ directoryPackagesPropsPath,
+ usesCpm,
+ agentPkg,
+ dryRun);
+ AddOperation(result, agentPackage);
+
+ if (candidate.NeedsBlazor)
+ {
+ var blazorPkg = isGtk ? manifest.Packages.BlazorGtk : manifest.Packages.Blazor;
+ var blazorPackage = EnsurePackageReference(
+ candidate.ProjectPath,
+ directoryPackagesPropsPath,
+ usesCpm,
+ blazorPkg,
+ dryRun);
+ AddOperation(result, blazorPackage);
+ }
+
+ if (candidate.MauiProgramPath == null)
+ {
+ var agentNs = isGtk ? "Microsoft.Maui.DevFlow.Agent.Gtk" : "Microsoft.Maui.DevFlow.Agent";
+ var snippet = $"using {agentNs};\n\n// Inside CreateMauiApp(), after var builder = MauiApp.CreateBuilder():\n#if DEBUG\nbuilder.AddMauiDevFlowAgent();\n#endif";
+ AddOperation(result, new DevFlowInitOperationResult
+ {
+ Name = "Patch MauiProgram.cs",
+ Status = DevFlowInitStatus.ManualRequired,
+ Detail = "Could not find MauiProgram.cs.",
+ ManualSteps =
+ [
+ $"Locate MauiProgram.cs (or equivalent) in {candidate.RelativePath} and add the following:",
+ $"```csharp\n{snippet}\n```"
+ ]
+ });
+ }
+ else
+ {
+ AddOperation(result, MauiProgramPatcher.EnsureRegistration(candidate.MauiProgramPath, candidate.NeedsBlazor, isGtk, dryRun));
+ }
+
+ result.OverallStatus = DetermineOverallStatus(result.Operations);
+ return result;
+ }
+
+ static void AddOperation(DevFlowInitProjectResult result, DevFlowInitOperationResult operation)
+ {
+ result.Operations.Add(operation);
+ foreach (var file in operation.FilesChanged)
+ {
+ if (!result.FilesChanged.Contains(file, StringComparer.OrdinalIgnoreCase))
+ result.FilesChanged.Add(file);
+ }
+
+ foreach (var step in operation.ManualSteps)
+ {
+ if (!result.ManualSteps.Contains(step, StringComparer.Ordinal))
+ result.ManualSteps.Add(step);
+ }
+ }
+
+ static string DetermineOverallStatus(IEnumerable operations)
+ {
+ var statuses = operations.Select(operation => operation.Status).ToList();
+ if (statuses.Contains(DevFlowInitStatus.Failed, StringComparer.Ordinal))
+ return DevFlowInitStatus.Failed;
+ if (statuses.Contains(DevFlowInitStatus.ManualRequired, StringComparer.Ordinal))
+ return DevFlowInitStatus.ManualRequired;
+ if (statuses.All(status => status == DevFlowInitStatus.AlreadyPresent))
+ return DevFlowInitStatus.AlreadyPresent;
+ return DevFlowInitStatus.Success;
+ }
+
+ ///
+ /// Detect whether the project uses Central Package Management by asking dotnet msbuild for the
+ /// evaluated ManagePackageVersionsCentrally property (which is set by
+ /// Directory.Packages.props). Falls back to walking up the directory tree for
+ /// Directory.Packages.props if evaluation is unavailable.
+ ///
+ static bool DetectCentralPackageManagement(string projectPath, string workspaceRoot, out string? directoryPackagesPropsPath)
+ {
+ directoryPackagesPropsPath = FindDirectoryPackagesProps(projectPath, workspaceRoot);
+
+ var rawValue = DotnetCliProjectReader.GetProperty(projectPath, "ManagePackageVersionsCentrally");
+ if (!string.IsNullOrEmpty(rawValue))
+ return string.Equals(rawValue, "true", StringComparison.OrdinalIgnoreCase);
+
+ // Fallback: if the file simply exists on disk, assume CPM is in effect
+ // (the property defaults to true when Directory.Packages.props is present).
+ return directoryPackagesPropsPath != null;
+ }
+
+ static string? FindDirectoryPackagesProps(string projectPath, string workspaceRoot)
+ {
+ var current = Path.GetDirectoryName(projectPath);
+ var rootFull = Path.GetFullPath(workspaceRoot);
+ while (!string.IsNullOrEmpty(current)
+ && current.StartsWith(rootFull, StringComparison.OrdinalIgnoreCase))
+ {
+ var propsPath = Path.Combine(current, "Directory.Packages.props");
+ if (File.Exists(propsPath))
+ return propsPath;
+
+ current = Path.GetDirectoryName(current);
+ }
+
+ return null;
+ }
+
+ static DevFlowInitOperationResult EnsurePackageReference(
+ string projectPath,
+ string? directoryPackagesPropsPath,
+ bool useCentralPackageManagement,
+ DevFlowNuGetPackageManifest package,
+ bool dryRun)
+ {
+ // Check if the package is already referenced (using evaluated data from dotnet msbuild).
+ var existingIds = DotnetCliProjectReader.GetPackageReferenceIds(projectPath);
+ if (existingIds.Contains(package.PackageId))
+ {
+ return new DevFlowInitOperationResult
+ {
+ Name = $"Ensure {package.PackageId}",
+ Status = DevFlowInitStatus.AlreadyPresent,
+ Detail = $"{package.PackageId} is already configured."
+ };
+ }
+
+ if (dryRun)
+ {
+ return new DevFlowInitOperationResult
+ {
+ Name = $"Ensure {package.PackageId}",
+ Status = DevFlowInitStatus.Success,
+ Detail = $"Would add {package.PackageId} (dry-run).",
+ FilesChanged = [projectPath]
+ };
+ }
+
+ // Use `dotnet add package --no-restore` to add the reference. This handles both
+ // regular projects (adds version to csproj) and CPM projects (adds PackageReference
+ // to csproj without version, adds PackageVersion to Directory.Packages.props).
+ try
+ {
+ var psi = new System.Diagnostics.ProcessStartInfo
+ {
+ FileName = "dotnet",
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+ psi.ArgumentList.Add("add");
+ psi.ArgumentList.Add(projectPath);
+ psi.ArgumentList.Add("package");
+ psi.ArgumentList.Add(package.PackageId);
+ psi.ArgumentList.Add("--version");
+ psi.ArgumentList.Add(package.Version);
+ psi.ArgumentList.Add("--no-restore");
+
+ using var process = System.Diagnostics.Process.Start(psi);
+ if (process == null)
+ {
+ return new DevFlowInitOperationResult
+ {
+ Name = $"Ensure {package.PackageId}",
+ Status = DevFlowInitStatus.Failed,
+ Detail = $"Could not start `dotnet add package`.",
+ ManualSteps = [$"Run: dotnet add {Path.GetFileName(projectPath)} package {package.PackageId} --version {package.Version}"]
+ };
+ }
+
+ var stdoutTask = process.StandardOutput.ReadToEndAsync();
+ var stderrTask = process.StandardError.ReadToEndAsync();
+ process.WaitForExit(30_000);
+ var stderr = stderrTask.GetAwaiter().GetResult();
+
+ if (process.ExitCode != 0)
+ {
+ var detail = !string.IsNullOrWhiteSpace(stderr) ? stderr.Trim() : "Unknown error";
+ return new DevFlowInitOperationResult
+ {
+ Name = $"Ensure {package.PackageId}",
+ Status = DevFlowInitStatus.Failed,
+ Detail = $"dotnet add package failed: {detail}",
+ ManualSteps = [$"Run: dotnet add {Path.GetFileName(projectPath)} package {package.PackageId} --version {package.Version}"]
+ };
+ }
+
+ var filesChanged = new List { projectPath };
+
+ // dotnet add package --no-restore does not handle CPM, so post-process:
+ // strip Version from the PackageReference in the csproj, and add a
+ // PackageVersion entry to Directory.Packages.props.
+ if (useCentralPackageManagement && directoryPackagesPropsPath != null)
+ {
+ PostProcessCpmPackageReference(projectPath, directoryPackagesPropsPath, package.PackageId, package.Version);
+ filesChanged.Add(directoryPackagesPropsPath);
+ }
+
+ return new DevFlowInitOperationResult
+ {
+ Name = $"Ensure {package.PackageId}",
+ Status = DevFlowInitStatus.Success,
+ Detail = $"Configured {package.PackageId}.",
+ FilesChanged = filesChanged
+ };
+ }
+ catch (Exception ex)
+ {
+ return new DevFlowInitOperationResult
+ {
+ Name = $"Ensure {package.PackageId}",
+ Status = DevFlowInitStatus.Failed,
+ Detail = $"Could not add {package.PackageId}: {ex.Message}",
+ ManualSteps = [$"Run: dotnet add {Path.GetFileName(projectPath)} package {package.PackageId} --version {package.Version}"]
+ };
+ }
+ }
+
+ ///
+ /// After dotnet add package --no-restore, which always writes the Version attribute
+ /// into the csproj PackageReference, fixup for CPM: strip the Version from the csproj and
+ /// add a PackageVersion entry to Directory.Packages.props.
+ ///
+ static void PostProcessCpmPackageReference(string projectPath, string directoryPackagesPropsPath, string packageId, string version)
+ {
+ // 1. Remove Version attribute from the PackageReference in the csproj
+ var projectDoc = System.Xml.Linq.XDocument.Load(projectPath, System.Xml.Linq.LoadOptions.PreserveWhitespace);
+ var ns = projectDoc.Root?.Name.Namespace ?? System.Xml.Linq.XNamespace.None;
+ var pkgRef = projectDoc.Root?
+ .Descendants(ns + "PackageReference")
+ .FirstOrDefault(e => string.Equals(e.Attribute("Include")?.Value, packageId, StringComparison.OrdinalIgnoreCase));
+ if (pkgRef != null)
+ {
+ pkgRef.Attribute("Version")?.Remove();
+ using var writer = new System.IO.StreamWriter(projectPath, false, new System.Text.UTF8Encoding(true));
+ projectDoc.Save(writer, System.Xml.Linq.SaveOptions.DisableFormatting);
+ }
+
+ // 2. Add PackageVersion to Directory.Packages.props
+ var propsDoc = System.Xml.Linq.XDocument.Load(directoryPackagesPropsPath, System.Xml.Linq.LoadOptions.PreserveWhitespace);
+ var propsNs = propsDoc.Root?.Name.Namespace ?? System.Xml.Linq.XNamespace.None;
+ var existingPv = propsDoc.Root?
+ .Descendants(propsNs + "PackageVersion")
+ .FirstOrDefault(e => string.Equals(e.Attribute("Include")?.Value, packageId, StringComparison.OrdinalIgnoreCase));
+ if (existingPv == null)
+ {
+ var itemGroup = propsDoc.Root?.Descendants(propsNs + "ItemGroup").LastOrDefault();
+ if (itemGroup == null)
+ {
+ itemGroup = new System.Xml.Linq.XElement(propsNs + "ItemGroup");
+ propsDoc.Root?.Add(itemGroup);
+ }
+ itemGroup.Add(new System.Xml.Linq.XElement(propsNs + "PackageVersion",
+ new System.Xml.Linq.XAttribute("Include", packageId),
+ new System.Xml.Linq.XAttribute("Version", version)));
+ using var writer = new System.IO.StreamWriter(directoryPackagesPropsPath, false, new System.Text.UTF8Encoding(true));
+ propsDoc.Save(writer, System.Xml.Linq.SaveOptions.DisableFormatting);
+ }
+ else
+ {
+ existingPv.SetAttributeValue("Version", version);
+ using var writer = new System.IO.StreamWriter(directoryPackagesPropsPath, false, new System.Text.UTF8Encoding(true));
+ propsDoc.Save(writer, System.Xml.Linq.SaveOptions.DisableFormatting);
+ }
+ }
+}
diff --git a/src/Cli/Microsoft.Maui.Cli/DevFlow/Init/DotnetCliProjectReader.cs b/src/Cli/Microsoft.Maui.Cli/DevFlow/Init/DotnetCliProjectReader.cs
new file mode 100644
index 000000000..dc9231a46
--- /dev/null
+++ b/src/Cli/Microsoft.Maui.Cli/DevFlow/Init/DotnetCliProjectReader.cs
@@ -0,0 +1,155 @@
+using System.Text.Json;
+
+namespace Microsoft.Maui.Cli.DevFlow.Init;
+
+///
+/// Reads evaluated MSBuild properties and items by shelling out to
+/// dotnet msbuild -getProperty / -getItem. This avoids
+/// any dependency on Microsoft.Build NuGet packages, MSBuild Locator,
+/// or DOTNET_ROOT environment variables.
+///
+internal static class DotnetCliProjectReader
+{
+ ///
+ /// Get one or more evaluated MSBuild properties from a project.
+ /// Returns a dictionary of property name → value. Missing or empty
+ /// properties are omitted from the result.
+ ///
+ public static Dictionary GetProperties(string projectPath, params string[] propertyNames)
+ {
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ if (propertyNames.Length == 0)
+ return result;
+
+ var joined = string.Join(',', propertyNames);
+ var json = RunDotnetMsBuild(projectPath, $"-getProperty:{joined}");
+ if (json == null)
+ return result;
+
+ try
+ {
+ using var doc = JsonDocument.Parse(json);
+ if (doc.RootElement.TryGetProperty("Properties", out var props))
+ {
+ foreach (var name in propertyNames)
+ {
+ if (props.TryGetProperty(name, out var val))
+ {
+ var s = val.GetString();
+ if (!string.IsNullOrEmpty(s))
+ result[name] = s;
+ }
+ }
+ }
+ }
+ catch { }
+
+ return result;
+ }
+
+ ///
+ /// Get a single evaluated property value, or empty string if not found.
+ ///
+ public static string GetProperty(string projectPath, string propertyName)
+ {
+ var dict = GetProperties(projectPath, propertyName);
+ return dict.TryGetValue(propertyName, out var value) ? value : string.Empty;
+ }
+
+ ///
+ /// Get a boolean property value.
+ ///
+ public static bool GetBooleanProperty(string projectPath, string propertyName)
+ => string.Equals(GetProperty(projectPath, propertyName), "true", StringComparison.OrdinalIgnoreCase);
+
+ ///
+ /// Get all PackageReference identities from a project (fully evaluated, including
+ /// items from Directory.Build.props, Directory.Packages.props, etc.).
+ ///
+ public static HashSet GetPackageReferenceIds(string projectPath)
+ {
+ var result = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var json = RunDotnetMsBuild(projectPath, "-getItem:PackageReference");
+ if (json == null)
+ return result;
+
+ try
+ {
+ using var doc = JsonDocument.Parse(json);
+ if (doc.RootElement.TryGetProperty("Items", out var items) &&
+ items.TryGetProperty("PackageReference", out var refs))
+ {
+ foreach (var item in refs.EnumerateArray())
+ {
+ if (item.TryGetProperty("Identity", out var id))
+ {
+ var s = id.GetString();
+ if (!string.IsNullOrWhiteSpace(s))
+ result.Add(s);
+ }
+ }
+ }
+ }
+ catch { }
+
+ return result;
+ }
+
+ ///
+ /// Get target frameworks from the evaluated project.
+ ///
+ public static IReadOnlyList GetTargetFrameworks(string projectPath)
+ {
+ var props = GetProperties(projectPath, "TargetFramework", "TargetFrameworks");
+ var list = new List();
+
+ if (props.TryGetValue("TargetFramework", out var tf))
+ AddEntries(list, tf);
+ if (props.TryGetValue("TargetFrameworks", out var tfs))
+ AddEntries(list, tfs);
+
+ return list
+ .Where(v => !string.IsNullOrWhiteSpace(v))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ static void AddEntries(List list, string raw)
+ {
+ foreach (var entry in raw.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ list.Add(entry);
+ }
+ }
+
+ static string? RunDotnetMsBuild(string projectPath, string msbuildArg)
+ {
+ try
+ {
+ var psi = new System.Diagnostics.ProcessStartInfo
+ {
+ FileName = "dotnet",
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+ psi.ArgumentList.Add("msbuild");
+ psi.ArgumentList.Add(projectPath);
+ psi.ArgumentList.Add(msbuildArg);
+
+ using var process = System.Diagnostics.Process.Start(psi);
+ if (process == null) return null;
+
+ var stdout = process.StandardOutput.ReadToEnd();
+ process.WaitForExit(15_000);
+
+ if (process.ExitCode != 0)
+ return null;
+
+ return string.IsNullOrWhiteSpace(stdout) ? null : stdout;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+}
diff --git a/src/Cli/Microsoft.Maui.Cli/DevFlow/Init/GitHubDirectorySync.cs b/src/Cli/Microsoft.Maui.Cli/DevFlow/Init/GitHubDirectorySync.cs
new file mode 100644
index 000000000..3f58cee38
--- /dev/null
+++ b/src/Cli/Microsoft.Maui.Cli/DevFlow/Init/GitHubDirectorySync.cs
@@ -0,0 +1,167 @@
+using System.Net.Http.Headers;
+using System.Text.Json.Nodes;
+
+namespace Microsoft.Maui.Cli.DevFlow.Init;
+
+internal sealed class GitHubSyncRequest
+{
+ public required string Repo { get; init; }
+ public required string RepoUrl { get; init; }
+ public required string SourcePath { get; init; }
+ public required string Ref { get; init; }
+ public required string DestinationRoot { get; init; }
+ public required string MetadataFileName { get; init; }
+ public string? ManifestVersion { get; init; }
+ public bool DryRun { get; init; }
+}
+
+internal sealed class GitHubSyncResult
+{
+ public string CommitSha { get; init; } = "";
+ public string MetadataPath { get; init; } = "";
+ public IReadOnlyList DownloadedFiles { get; init; } = [];
+}
+
+internal sealed class GitHubSyncMetadata
+{
+ public string Commit { get; set; } = "";
+ public string UpdatedAt { get; set; } = "";
+ public string Branch { get; set; } = "";
+ public string Repo { get; set; } = "";
+ public string RepoUrl { get; set; } = "";
+ public string SourcePath { get; set; } = "";
+ public string ManifestVersion { get; set; } = "";
+}
+
+internal static class GitHubDirectorySync
+{
+ public static HttpClient CreateHttpClient()
+ {
+ var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
+ http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("Microsoft.Maui.DevFlow-CLI", "1.0"));
+ http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
+
+ var ghToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN")
+ ?? Environment.GetEnvironmentVariable("GH_TOKEN");
+ if (!string.IsNullOrWhiteSpace(ghToken))
+ http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", ghToken);
+
+ return http;
+ }
+
+ public static async Task> ListFilesAsync(HttpClient http, string repo, string basePath, string gitRef, CancellationToken cancellationToken = default)
+ {
+ var files = new List();
+ await ListGitHubDirectoryAsync(http, repo, basePath, "", files, gitRef, cancellationToken);
+ files.Sort(StringComparer.OrdinalIgnoreCase);
+ return files;
+ }
+
+ public static async Task GetLatestCommitShaAsync(HttpClient http, string repo, string basePath, string gitRef, CancellationToken cancellationToken = default)
+ {
+ var url = $"https://api.github.com/repos/{repo}/commits?path={basePath}&sha={gitRef}&per_page=1";
+ var json = await http.GetStringAsync(url, cancellationToken);
+ var commits = CliJson.ParseElement(json);
+ foreach (var commit in commits.EnumerateArray())
+ return commit.GetProperty("sha").GetString();
+
+ return null;
+ }
+
+ public static async Task ReadMetadataAsync(string metadataPath)
+ {
+ if (!File.Exists(metadataPath))
+ return null;
+
+ try
+ {
+ var json = await File.ReadAllTextAsync(metadataPath);
+ var doc = CliJson.ParseElement(json);
+
+ return new GitHubSyncMetadata
+ {
+ Commit = doc.TryGetProperty("commit", out var commit) ? commit.GetString() ?? "" : "",
+ UpdatedAt = doc.TryGetProperty("updatedAt", out var updatedAt) ? updatedAt.GetString() ?? "" : "",
+ Branch = doc.TryGetProperty("branch", out var branch) ? branch.GetString() ?? "" : "",
+ Repo = doc.TryGetProperty("repo", out var repo) ? repo.GetString() ?? "" : "",
+ RepoUrl = doc.TryGetProperty("repoUrl", out var repoUrl) ? repoUrl.GetString() ?? "" : "",
+ SourcePath = doc.TryGetProperty("sourcePath", out var sourcePath) ? sourcePath.GetString() ?? "" : "",
+ ManifestVersion = doc.TryGetProperty("manifestVersion", out var manifestVersion) ? manifestVersion.GetString() ?? "" : ""
+ };
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ public static async Task SyncAsync(HttpClient http, GitHubSyncRequest request, CancellationToken cancellationToken = default)
+ {
+ var files = await ListFilesAsync(http, request.Repo, request.SourcePath, request.Ref, cancellationToken);
+ if (files.Count == 0)
+ throw new InvalidOperationException($"No files found at {request.Repo}/{request.SourcePath}@{request.Ref}.");
+
+ var downloaded = new List();
+ foreach (var file in files)
+ {
+ var url = $"https://raw.githubusercontent.com/{request.Repo}/{request.Ref}/{request.SourcePath}/{file}";
+ var destPath = Path.GetFullPath(Path.Combine(request.DestinationRoot, file));
+ var rootFull = Path.GetFullPath(request.DestinationRoot + Path.DirectorySeparatorChar);
+ if (!destPath.StartsWith(rootFull, StringComparison.OrdinalIgnoreCase))
+ throw new InvalidOperationException($"Refusing to write outside destination root: {file}");
+ downloaded.Add(destPath);
+
+ if (request.DryRun)
+ continue;
+
+ var content = await http.GetStringAsync(url, cancellationToken);
+ Directory.CreateDirectory(Path.GetDirectoryName(destPath)!);
+ await File.WriteAllTextAsync(destPath, content, cancellationToken);
+ }
+
+ var commitSha = await GetLatestCommitShaAsync(http, request.Repo, request.SourcePath, request.Ref, cancellationToken) ?? request.Ref;
+ var metadataPath = Path.Combine(request.DestinationRoot, request.MetadataFileName);
+ if (!request.DryRun)
+ {
+ Directory.CreateDirectory(request.DestinationRoot);
+ var metadata = new JsonObject
+ {
+ ["commit"] = commitSha,
+ ["updatedAt"] = DateTime.UtcNow.ToString("o"),
+ ["branch"] = request.Ref,
+ ["repo"] = request.Repo,
+ ["repoUrl"] = request.RepoUrl,
+ ["sourcePath"] = request.SourcePath,
+ ["manifestVersion"] = request.ManifestVersion ?? string.Empty
+ };
+ await File.WriteAllTextAsync(metadataPath, CliJson.SerializeUntyped(metadata, indented: true), cancellationToken);
+ }
+
+ return new GitHubSyncResult
+ {
+ CommitSha = commitSha,
+ MetadataPath = metadataPath,
+ DownloadedFiles = downloaded
+ };
+ }
+
+ static async Task ListGitHubDirectoryAsync(HttpClient http, string repo, string basePath, string relativePath, List files, string gitRef, CancellationToken cancellationToken = default)
+ {
+ var apiPath = string.IsNullOrEmpty(relativePath) ? basePath : $"{basePath}/{relativePath}";
+ var url = $"https://api.github.com/repos/{repo}/contents/{apiPath}?ref={gitRef}";
+ var json = await http.GetStringAsync(url, cancellationToken);
+ var items = CliJson.ParseElement(json);
+
+ foreach (var item in items.EnumerateArray())
+ {
+ var name = item.GetProperty("name").GetString()!;
+ var type = item.GetProperty("type").GetString()!;
+ var itemRelative = string.IsNullOrEmpty(relativePath) ? name : $"{relativePath}/{name}";
+
+ if (type == "file")
+ files.Add(itemRelative);
+ else if (type == "dir")
+ await ListGitHubDirectoryAsync(http, repo, basePath, itemRelative, files, gitRef, cancellationToken);
+ }
+ }
+}
diff --git a/src/Cli/Microsoft.Maui.Cli/DevFlow/Init/MauiProgramPatcher.cs b/src/Cli/Microsoft.Maui.Cli/DevFlow/Init/MauiProgramPatcher.cs
new file mode 100644
index 000000000..3718195de
--- /dev/null
+++ b/src/Cli/Microsoft.Maui.Cli/DevFlow/Init/MauiProgramPatcher.cs
@@ -0,0 +1,169 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Microsoft.Maui.Cli.DevFlow.Init;
+
+internal static class MauiProgramPatcher
+{
+ public static DevFlowInitOperationResult EnsureRegistration(string filePath, bool includeBlazor, bool isGtk, bool dryRun)
+ {
+ if (!File.Exists(filePath))
+ {
+ return new DevFlowInitOperationResult
+ {
+ Name = "Patch MauiProgram.cs",
+ Status = DevFlowInitStatus.ManualRequired,
+ Detail = "Could not find MauiProgram.cs.",
+ ManualSteps = [$"Add builder.AddMauiDevFlowAgent() manually in {Path.GetFileName(filePath)}."]
+ };
+ }
+
+ var text = File.ReadAllText(filePath);
+ var hasAgentRegistration = text.Contains("AddMauiDevFlowAgent", StringComparison.Ordinal);
+ var hasBlazorRegistration = text.Contains("AddMauiBlazorDevFlowTools", StringComparison.Ordinal);
+ if (hasAgentRegistration && (!includeBlazor || hasBlazorRegistration))
+ {
+ return new DevFlowInitOperationResult
+ {
+ Name = "Patch MauiProgram.cs",
+ Status = DevFlowInitStatus.AlreadyPresent,
+ Detail = "MauiProgram.cs already contains the required DevFlow registration."
+ };
+ }
+
+ var agentNamespace = isGtk ? "Microsoft.Maui.DevFlow.Agent.Gtk" : "Microsoft.Maui.DevFlow.Agent";
+ var blazorNamespace = isGtk ? "Microsoft.Maui.DevFlow.Blazor.Gtk" : "Microsoft.Maui.DevFlow.Blazor";
+
+ var tree = CSharpSyntaxTree.ParseText(text);
+ var root = tree.GetCompilationUnitRoot();
+ var builderName = FindBuilderVariableName(root);
+ var returnStatement = FindBuilderReturnStatement(root, builderName);
+ if (builderName == null || returnStatement == null)
+ {
+ return new DevFlowInitOperationResult
+ {
+ Name = "Patch MauiProgram.cs",
+ Status = DevFlowInitStatus.ManualRequired,
+ Detail = "Could not confidently locate the MAUI app builder or return statement.",
+ ManualSteps =
+ [
+ $"Add `using {agentNamespace};`.",
+ "Add `builder.AddMauiDevFlowAgent();` inside `#if DEBUG` before `return builder.Build();`."
+ ]
+ };
+ }
+
+ var newline = DetectNewline(text);
+ var (lineStart, indent) = GetLineStartAndIndentation(text, returnStatement.SpanStart);
+ var missingCalls = new List();
+ if (!hasAgentRegistration)
+ missingCalls.Add($"{builderName}.AddMauiDevFlowAgent();");
+ if (includeBlazor && !hasBlazorRegistration)
+ missingCalls.Add($"{builderName}.AddMauiBlazorDevFlowTools();");
+
+ var insertion = BuildRegistrationBlock(indent, newline, missingCalls);
+ var updated = text.Insert(lineStart, insertion);
+ var updatedRoot = CSharpSyntaxTree.ParseText(updated).GetCompilationUnitRoot();
+ updated = EnsureUsing(updated, updatedRoot, agentNamespace, newline);
+ if (includeBlazor)
+ {
+ updatedRoot = CSharpSyntaxTree.ParseText(updated).GetCompilationUnitRoot();
+ updated = EnsureUsing(updated, updatedRoot, blazorNamespace, newline);
+ }
+
+ if (!dryRun)
+ File.WriteAllText(filePath, updated);
+
+ return new DevFlowInitOperationResult
+ {
+ Name = "Patch MauiProgram.cs",
+ Status = DevFlowInitStatus.Success,
+ Detail = "Added DevFlow registration to MauiProgram.cs.",
+ FilesChanged = [filePath]
+ };
+ }
+
+ static string EnsureUsing(string text, CompilationUnitSyntax root, string namespaceName, string newline)
+ {
+ if (root.Usings.Any(usingDirective => usingDirective.Name?.ToString() == namespaceName))
+ return text;
+
+ if (root.Usings.Count > 0)
+ {
+ // Insert after the semicolon of the last using (Span.End, not FullSpan.End)
+ // so that any trailing trivia (blank lines before namespace) is preserved.
+ var position = root.Usings.Last().Span.End;
+ return text.Insert(position, $"{newline}using {namespaceName};");
+ }
+
+ return $"using {namespaceName};{newline}{newline}{text}";
+ }
+
+ ///
+ /// Detects the dominant line ending in .
+ ///
+ static string DetectNewline(string text)
+ => text.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n";
+
+ static string? FindBuilderVariableName(CompilationUnitSyntax root)
+ {
+ foreach (var declaration in root.DescendantNodes().OfType())
+ {
+ if (declaration.Initializer?.Value is InvocationExpressionSyntax invocation &&
+ invocation.Expression is MemberAccessExpressionSyntax memberAccess &&
+ memberAccess.Name.Identifier.Text == "CreateBuilder" &&
+ memberAccess.Expression.ToString() == "MauiApp")
+ {
+ return declaration.Identifier.Text;
+ }
+ }
+
+ return null;
+ }
+
+ static ReturnStatementSyntax? FindBuilderReturnStatement(CompilationUnitSyntax root, string? builderName)
+ {
+ if (builderName == null)
+ return null;
+
+ return root.DescendantNodes().OfType()
+ .FirstOrDefault(returnStatement =>
+ returnStatement.Expression is InvocationExpressionSyntax invocation &&
+ invocation.Expression is MemberAccessExpressionSyntax memberAccess &&
+ memberAccess.Name.Identifier.Text == "Build" &&
+ memberAccess.Expression.ToString() == builderName);
+ }
+
+ ///
+ /// Returns the position of the first character on the line containing
+ /// and the whitespace indent string.
+ ///
+ static (int lineStart, string indent) GetLineStartAndIndentation(string text, int position)
+ {
+ var lineStart = text.LastIndexOf('\n', Math.Max(0, position - 1));
+ lineStart = lineStart < 0 ? 0 : lineStart + 1;
+
+ // Skip past a \r that might follow the \n (rare but possible in mixed files)
+ if (lineStart < text.Length && text[lineStart] == '\r')
+ lineStart++;
+
+ var end = lineStart;
+ while (end < text.Length && (text[end] == ' ' || text[end] == '\t'))
+ end++;
+
+ return (lineStart, text[lineStart..end]);
+ }
+
+ static string BuildRegistrationBlock(string indent, string newline, IReadOnlyList calls)
+ {
+ var block = new List
+ {
+ "#if DEBUG"
+ };
+ block.AddRange(calls.Select(call => $"{indent}{call}"));
+ block.Add("#endif");
+ block.Add(string.Empty);
+ return string.Join(newline, block);
+ }
+}
diff --git a/src/Cli/Microsoft.Maui.Cli/DevFlow/Init/devflow-init-manifest.json b/src/Cli/Microsoft.Maui.Cli/DevFlow/Init/devflow-init-manifest.json
new file mode 100644
index 000000000..46c47e4c3
--- /dev/null
+++ b/src/Cli/Microsoft.Maui.Cli/DevFlow/Init/devflow-init-manifest.json
@@ -0,0 +1,86 @@
+{
+ "schemaVersion": 1,
+ "manifestVersion": "2026-04-23.1",
+ "packages": {
+ "agent": {
+ "packageId": "Microsoft.Maui.DevFlow.Agent",
+ "version": "0.1.0-preview.5"
+ },
+ "blazor": {
+ "packageId": "Microsoft.Maui.DevFlow.Blazor",
+ "version": "0.1.0-preview.5"
+ },
+ "agentGtk": {
+ "packageId": "Microsoft.Maui.DevFlow.Agent.Gtk",
+ "version": "0.1.0-preview.5"
+ },
+ "blazorGtk": {
+ "packageId": "Microsoft.Maui.DevFlow.Blazor.Gtk",
+ "version": "0.1.0-preview.5"
+ }
+ },
+ "hosts": [
+ {
+ "id": "claude",
+ "displayName": "Claude Code",
+ "detect": {
+ "executables": [ "claude" ],
+ "repoMarkers": [ ".claude", "CLAUDE.md" ],
+ "configMarkers": []
+ },
+ "marketplaceInstalls": [],
+ "repoLocalFallbacks": [
+ {
+ "sourceRepo": "dotnet/maui-labs",
+ "sourceRepoUrl": "https://github.com/dotnet/maui-labs",
+ "sourcePath": "plugins/dotnet-maui/skills/devflow-onboard",
+ "desiredRef": "main",
+ "targetPathTemplate": ".claude/skills/devflow-onboard",
+ "syncMetadataFileName": ".skill-version"
+ }
+ ],
+ "verify": {
+ "manualSteps": [
+ "Open the synced skill under .claude/skills/devflow-onboard if you want to inspect what was installed."
+ ]
+ }
+ },
+ {
+ "id": "copilot",
+ "displayName": "GitHub Copilot",
+ "detect": {
+ "executables": [ "copilot" ],
+ "repoMarkers": [ ".github", ".github/skills" ],
+ "configMarkers": []
+ },
+ "marketplaceInstalls": [
+ {
+ "marketplaceId": "dotnet/maui-labs",
+ "pluginId": "dotnet-maui",
+ "desiredVersion": "0.1.0",
+ "installStrategy": "manual",
+ "updatePolicy": "manual",
+ "manualSteps": [
+ "/plugin marketplace add dotnet/maui-labs",
+ "/plugin install dotnet-maui@dotnet-maui-labs"
+ ]
+ }
+ ],
+ "repoLocalFallbacks": [
+ {
+ "sourceRepo": "dotnet/maui-labs",
+ "sourceRepoUrl": "https://github.com/dotnet/maui-labs",
+ "sourcePath": "plugins/dotnet-maui/skills/devflow-onboard",
+ "desiredRef": "main",
+ "targetPathTemplate": ".github/skills/devflow-onboard",
+ "syncMetadataFileName": ".skill-version"
+ }
+ ],
+ "verify": {
+ "manualSteps": [
+ "If the host does not pick up repo-local skills automatically, use the marketplace/plugin commands above."
+ ]
+ }
+ }
+ ]
+}
diff --git a/src/Cli/Microsoft.Maui.Cli/Microsoft.Maui.Cli.csproj b/src/Cli/Microsoft.Maui.Cli/Microsoft.Maui.Cli.csproj
index 679b20a9d..8a9ead9eb 100644
--- a/src/Cli/Microsoft.Maui.Cli/Microsoft.Maui.Cli.csproj
+++ b/src/Cli/Microsoft.Maui.Cli/Microsoft.Maui.Cli.csproj
@@ -25,6 +25,7 @@
+
@@ -68,6 +69,7 @@
+
diff --git a/src/DevFlow/README.md b/src/DevFlow/README.md
index f373a06fb..593187417 100644
--- a/src/DevFlow/README.md
+++ b/src/DevFlow/README.md
@@ -19,6 +19,13 @@ A comprehensive testing, automation, and debugging toolkit for .NET MAUI applica
## Quick Start
+### Recommended onboarding
+
+```bash
+dotnet tool install -g Microsoft.Maui.Cli --prerelease
+maui devflow init
+```
+
### 1. Install the NuGet packages
```xml
diff --git a/tests/dotnet-maui/devflow-onboard/eval.yaml b/tests/dotnet-maui/devflow-onboard/eval.yaml
new file mode 100644
index 000000000..4fe7d2dc5
--- /dev/null
+++ b/tests/dotnet-maui/devflow-onboard/eval.yaml
@@ -0,0 +1,42 @@
+scenarios:
+ - name: "Onboard existing MAUI app"
+ prompt: |
+ I just installed the dotnet-maui plugin. I have an existing .NET MAUI app in this repo and I want DevFlow set up for it.
+ assertions:
+ - type: "output_contains"
+ value: "maui devflow init"
+ - type: "output_contains"
+ value: "MAUI-DEVFLOW-INIT-REPORT.md"
+ rubric:
+ - "Agent uses maui devflow init as the primary onboarding action"
+ - "Agent treats MAUI-DEVFLOW-INIT-REPORT.md as the follow-up source of truth"
+ - "Agent does not jump straight to connection troubleshooting"
+ timeout: 120
+
+ - name: "Multiple projects in a workspace"
+ prompt: |
+ My workspace has several MAUI apps and I only want to onboard some of them for DevFlow. What should I do?
+ assertions:
+ - type: "output_contains"
+ value: "maui devflow init"
+ - type: "output_matches"
+ pattern: "--project|--all|select"
+ rubric:
+ - "Agent explains that init can target one or more projects in a workspace"
+ - "Agent mentions either interactive selection or the --project/--all controls"
+ - "Agent keeps the workflow centered on the init command"
+ timeout: 120
+
+ - name: "Resume from init report"
+ prompt: |
+ I already ran maui devflow init and it produced MAUI-DEVFLOW-INIT-REPORT.md. What should the agent look at next?
+ assertions:
+ - type: "output_contains"
+ value: "MAUI-DEVFLOW-INIT-REPORT.md"
+ - type: "output_matches"
+ pattern: "report|manual|diagnose|wait"
+ rubric:
+ - "Agent reads the init report before guessing about setup state"
+ - "Agent uses the report to decide whether to continue with verification or manual follow-up"
+ - "Agent reserves devflow-connect for post-setup connectivity problems"
+ timeout: 120