From 05de5b28abe6fec483a37159d17d4423f2e2ad8e Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Fri, 17 Apr 2026 08:55:40 +0200 Subject: [PATCH 01/31] Add `maui ai` command group for AI-assisted development bootstrapping New top-level `maui ai` command group with 5 subcommands: - `maui ai detect agent environments, fetch skills frominit` marketplace, install + configure MCP in one command - `maui ai show available skills from marketplacelist` - `maui ai show installed vs available skillsstatus` - `maui ai sync installed skills to latest versionsupdate` - `maui ai add < install a specific skill by nameskill>` Architecture: - MarketplaceClient: HTTP fetch marketplace.json/plugin.json, enumerate skills via GitHub Trees API, download files - AgentEnvironmentDetector: detect Claude Code, VS Code, Copilot CLI, OpenCode environments - McpConfigurator: merge maui-devflow MCP server entry into each environment's config file - SkillInstaller + SkillVersionStore: download skills and track versions via .skill-version files - AiJsonContext: AOT-compatible source-generated JSON serialization All commands support --json, --ci, --dry-run, --force modes. Supports GITHUB_TOKEN env var for authenticated API access. Closes #97 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AgentEnvironmentDetectorTests.cs | 186 ++++++++++ .../AiCommandsTests.cs | 169 +++++++++ .../MarketplaceClientTests.cs | 208 +++++++++++ .../McpConfiguratorTests.cs | 245 +++++++++++++ .../SkillVersionStoreTests.cs | 192 ++++++++++ .../Ai/AgentEnvironmentDetector.cs | 116 ++++++ .../Microsoft.Maui.Cli/Ai/AiJsonContext.cs | 21 ++ .../Ai/MarketplaceClient.cs | 330 ++++++++++++++++++ .../Microsoft.Maui.Cli/Ai/McpConfigurator.cs | 126 +++++++ .../Ai/Models/AgentEnvironment.cs | 49 +++ .../Ai/Models/InstalledSkillVersion.cs | 42 +++ .../Ai/Models/MarketplaceManifest.cs | 41 +++ .../Ai/Models/PluginManifest.cs | 30 ++ .../Microsoft.Maui.Cli/Ai/Models/SkillInfo.cs | 36 ++ .../Microsoft.Maui.Cli/Ai/SkillInstaller.cs | 71 ++++ .../Ai/SkillVersionStore.cs | 63 ++++ .../Commands/AiCommands.Add.cs | 183 ++++++++++ .../Commands/AiCommands.Init.cs | 312 +++++++++++++++++ .../Commands/AiCommands.List.cs | 88 +++++ .../Commands/AiCommands.Status.cs | 135 +++++++ .../Commands/AiCommands.Update.cs | 194 ++++++++++ .../Microsoft.Maui.Cli/Commands/AiCommands.cs | 65 ++++ src/Cli/Microsoft.Maui.Cli/Program.cs | 3 + 23 files changed, 2905 insertions(+) create mode 100644 src/Cli/Microsoft.Maui.Cli.UnitTests/AgentEnvironmentDetectorTests.cs create mode 100644 src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs create mode 100644 src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs create mode 100644 src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs create mode 100644 src/Cli/Microsoft.Maui.Cli.UnitTests/SkillVersionStoreTests.cs create mode 100644 src/Cli/Microsoft.Maui.Cli/Ai/AgentEnvironmentDetector.cs create mode 100644 src/Cli/Microsoft.Maui.Cli/Ai/AiJsonContext.cs create mode 100644 src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs create mode 100644 src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs create mode 100644 src/Cli/Microsoft.Maui.Cli/Ai/Models/AgentEnvironment.cs create mode 100644 src/Cli/Microsoft.Maui.Cli/Ai/Models/InstalledSkillVersion.cs create mode 100644 src/Cli/Microsoft.Maui.Cli/Ai/Models/MarketplaceManifest.cs create mode 100644 src/Cli/Microsoft.Maui.Cli/Ai/Models/PluginManifest.cs create mode 100644 src/Cli/Microsoft.Maui.Cli/Ai/Models/SkillInfo.cs create mode 100644 src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs create mode 100644 src/Cli/Microsoft.Maui.Cli/Ai/SkillVersionStore.cs create mode 100644 src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Add.cs create mode 100644 src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs create mode 100644 src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.List.cs create mode 100644 src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Status.cs create mode 100644 src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs create mode 100644 src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.cs diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/AgentEnvironmentDetectorTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/AgentEnvironmentDetectorTests.cs new file mode 100644 index 000000000..c272f1377 --- /dev/null +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/AgentEnvironmentDetectorTests.cs @@ -0,0 +1,186 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Maui.Cli.Ai; +using Microsoft.Maui.Cli.Ai.Models; +using Xunit; + +namespace Microsoft.Maui.Cli.UnitTests; + +public class AgentEnvironmentDetectorTests : IDisposable +{ + private readonly string _tempDir; + + public AgentEnvironmentDetectorTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, recursive: true); + } + + [Fact] + public void Detect_ClaudeDirectoryExists_DetectsClaudeEnvironment() + { + Directory.CreateDirectory(Path.Combine(_tempDir, ".claude")); + + var environments = AgentEnvironmentDetector.Detect(_tempDir); + + Assert.Contains(environments, e => e.Kind == AgentEnvironmentKind.Claude); + } + + [Fact] + public void Detect_VsCodeDirectoryExists_DetectsVsCodeEnvironment() + { + Directory.CreateDirectory(Path.Combine(_tempDir, ".vscode")); + + var environments = AgentEnvironmentDetector.Detect(_tempDir); + + Assert.Contains(environments, e => e.Kind == AgentEnvironmentKind.VsCode); + } + + [Fact] + public void Detect_OpenCodeDirectoryExists_DetectsOpenCodeEnvironment() + { + Directory.CreateDirectory(Path.Combine(_tempDir, ".opencode")); + + var environments = AgentEnvironmentDetector.Detect(_tempDir); + + Assert.Contains(environments, e => e.Kind == AgentEnvironmentKind.OpenCode); + } + + [Fact] + public void Detect_NoAgentDirectories_ReturnsEmptyOrOnlyCopilotCli() + { + // No .claude, .vscode, or .opencode directories + var environments = AgentEnvironmentDetector.Detect(_tempDir); + + // Only CopilotCli might be detected (if ~/.copilot exists on the machine) + Assert.DoesNotContain(environments, e => e.Kind == AgentEnvironmentKind.Claude); + Assert.DoesNotContain(environments, e => e.Kind == AgentEnvironmentKind.VsCode); + Assert.DoesNotContain(environments, e => e.Kind == AgentEnvironmentKind.OpenCode); + } + + [Fact] + public void Detect_MultipleEnvironments_DetectsAll() + { + Directory.CreateDirectory(Path.Combine(_tempDir, ".claude")); + Directory.CreateDirectory(Path.Combine(_tempDir, ".vscode")); + + var environments = AgentEnvironmentDetector.Detect(_tempDir); + + Assert.Contains(environments, e => e.Kind == AgentEnvironmentKind.Claude); + Assert.Contains(environments, e => e.Kind == AgentEnvironmentKind.VsCode); + } + + [Fact] + public void Detect_Claude_SkillsDirectoryIsCorrect() + { + Directory.CreateDirectory(Path.Combine(_tempDir, ".claude")); + + var environments = AgentEnvironmentDetector.Detect(_tempDir); + var claude = Assert.Single(environments, e => e.Kind == AgentEnvironmentKind.Claude); + + var expected = Path.Combine(_tempDir, ".claude", "skills"); + Assert.Equal(expected, claude.SkillsDirectory); + } + + [Fact] + public void Detect_Claude_McpConfigPathIsCorrect() + { + Directory.CreateDirectory(Path.Combine(_tempDir, ".claude")); + + var environments = AgentEnvironmentDetector.Detect(_tempDir); + var claude = Assert.Single(environments, e => e.Kind == AgentEnvironmentKind.Claude); + + var expected = Path.Combine(_tempDir, ".claude", "mcp.json"); + Assert.Equal(expected, claude.McpConfigPath); + } + + [Fact] + public void Detect_VsCode_SkillsDirectoryUsesGitHubPath() + { + Directory.CreateDirectory(Path.Combine(_tempDir, ".vscode")); + + var environments = AgentEnvironmentDetector.Detect(_tempDir); + var vscode = Assert.Single(environments, e => e.Kind == AgentEnvironmentKind.VsCode); + + var expected = Path.Combine(_tempDir, ".github", "skills"); + Assert.Equal(expected, vscode.SkillsDirectory); + } + + [Fact] + public void Detect_VsCode_McpConfigPathIsCorrect() + { + Directory.CreateDirectory(Path.Combine(_tempDir, ".vscode")); + + var environments = AgentEnvironmentDetector.Detect(_tempDir); + var vscode = Assert.Single(environments, e => e.Kind == AgentEnvironmentKind.VsCode); + + var expected = Path.Combine(_tempDir, ".vscode", "mcp.json"); + Assert.Equal(expected, vscode.McpConfigPath); + } + + [Fact] + public void Detect_OpenCode_SkillsDirectoryIsCorrect() + { + Directory.CreateDirectory(Path.Combine(_tempDir, ".opencode")); + + var environments = AgentEnvironmentDetector.Detect(_tempDir); + var opencode = Assert.Single(environments, e => e.Kind == AgentEnvironmentKind.OpenCode); + + var expected = Path.Combine(_tempDir, ".opencode", "skills"); + Assert.Equal(expected, opencode.SkillsDirectory); + } + + [Fact] + public void Detect_OpenCode_McpConfigPathIsCorrect() + { + Directory.CreateDirectory(Path.Combine(_tempDir, ".opencode")); + + var environments = AgentEnvironmentDetector.Detect(_tempDir); + var opencode = Assert.Single(environments, e => e.Kind == AgentEnvironmentKind.OpenCode); + + var expected = Path.Combine(_tempDir, ".opencode", "config.json"); + Assert.Equal(expected, opencode.McpConfigPath); + } + + [Fact] + public void Detect_McpConfigExists_ReflectsRealFilePresence() + { + var claudeDir = Path.Combine(_tempDir, ".claude"); + Directory.CreateDirectory(claudeDir); + + // Before creating mcp.json + var envBefore = AgentEnvironmentDetector.Detect(_tempDir); + var claudeBefore = Assert.Single(envBefore, e => e.Kind == AgentEnvironmentKind.Claude); + Assert.False(claudeBefore.McpConfigExists); + + // After creating mcp.json + File.WriteAllText(Path.Combine(claudeDir, "mcp.json"), "{}"); + var envAfter = AgentEnvironmentDetector.Detect(_tempDir); + var claudeAfter = Assert.Single(envAfter, e => e.Kind == AgentEnvironmentKind.Claude); + Assert.True(claudeAfter.McpConfigExists); + } + + [Fact] + public void Detect_StopsAtGitRoot() + { + // Create a git root with .claude at the root level + Directory.CreateDirectory(Path.Combine(_tempDir, ".git")); + Directory.CreateDirectory(Path.Combine(_tempDir, ".claude")); + + // Create a subdirectory to scan from + var subDir = Path.Combine(_tempDir, "src", "project"); + Directory.CreateDirectory(subDir); + + var environments = AgentEnvironmentDetector.Detect(subDir); + + // Should still find .claude at the git root + Assert.Contains(environments, e => e.Kind == AgentEnvironmentKind.Claude); + } +} diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs new file mode 100644 index 000000000..023b5b236 --- /dev/null +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs @@ -0,0 +1,169 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using Microsoft.Maui.Cli.Commands; +using Xunit; + +namespace Microsoft.Maui.Cli.UnitTests; + +public class AiCommandsTests +{ + [Fact] + public void Create_ReturnsCommandNamedAi() + { + var command = AiCommands.Create(); + + Assert.NotNull(command); + Assert.Equal("ai", command.Name); + } + + [Fact] + public void Create_HasFiveSubcommands() + { + var command = AiCommands.Create(); + + Assert.Equal(5, command.Subcommands.Count); + } + + [Theory] + [InlineData("init")] + [InlineData("list")] + [InlineData("status")] + [InlineData("update")] + [InlineData("add")] + public void Create_ContainsExpectedSubcommand(string subcommandName) + { + var command = AiCommands.Create(); + + Assert.Contains(command.Subcommands, c => c.Name == subcommandName); + } + + [Fact] + public void InitCommand_HasExpectedOptions() + { + var command = AiCommands.Create(); + var init = Assert.Single(command.Subcommands, c => c.Name == "init"); + + Assert.Contains(init.Options, o => o.Name == "--repo"); + Assert.Contains(init.Options, o => o.Name == "--branch"); + Assert.Contains(init.Options, o => o.Name == "--force"); + Assert.Contains(init.Options, o => o.Name == "--no-mcp"); + Assert.Contains(init.Options, o => o.Name == "--skill"); + Assert.Contains(init.Options, o => o.Name == "--env"); + } + + [Fact] + public void ListCommand_HasRepoAndBranchOptions() + { + var command = AiCommands.Create(); + var list = Assert.Single(command.Subcommands, c => c.Name == "list"); + + Assert.Contains(list.Options, o => o.Name == "--repo"); + Assert.Contains(list.Options, o => o.Name == "--branch"); + } + + [Fact] + public void StatusCommand_HasCheckUpdatesOption() + { + var command = AiCommands.Create(); + var status = Assert.Single(command.Subcommands, c => c.Name == "status"); + + Assert.Contains(status.Options, o => o.Name == "--repo"); + Assert.Contains(status.Options, o => o.Name == "--branch"); + Assert.Contains(status.Options, o => o.Name == "--check-updates"); + } + + [Fact] + public void UpdateCommand_HasExpectedOptions() + { + var command = AiCommands.Create(); + var update = Assert.Single(command.Subcommands, c => c.Name == "update"); + + Assert.Contains(update.Options, o => o.Name == "--repo"); + Assert.Contains(update.Options, o => o.Name == "--branch"); + Assert.Contains(update.Options, o => o.Name == "--force"); + Assert.Contains(update.Options, o => o.Name == "--skill"); + } + + [Fact] + public void AddCommand_HasRequiredSkillArgument() + { + var command = AiCommands.Create(); + var add = Assert.Single(command.Subcommands, c => c.Name == "add"); + + var skillArg = Assert.Single(add.Arguments); + Assert.Equal("skill", skillArg.Name); + } + + [Fact] + public void AddCommand_HasExpectedOptions() + { + var command = AiCommands.Create(); + var add = Assert.Single(command.Subcommands, c => c.Name == "add"); + + Assert.Contains(add.Options, o => o.Name == "--repo"); + Assert.Contains(add.Options, o => o.Name == "--branch"); + Assert.Contains(add.Options, o => o.Name == "--force"); + Assert.Contains(add.Options, o => o.Name == "--no-mcp"); + Assert.Contains(add.Options, o => o.Name == "--env"); + } + + [Fact] + public void BranchOption_HasShortAlias() + { + var command = AiCommands.Create(); + var init = Assert.Single(command.Subcommands, c => c.Name == "init"); + var branch = Assert.Single(init.Options, o => o.Name == "--branch"); + + Assert.Contains("-b", branch.Aliases); + } + + [Fact] + public void ForceOption_HasShortAlias() + { + var command = AiCommands.Create(); + var init = Assert.Single(command.Subcommands, c => c.Name == "init"); + var force = Assert.Single(init.Options, o => o.Name == "--force"); + + Assert.Contains("-y", force.Aliases); + } + + [Fact] + public void AiCommand_AllOptionsHaveValidAliases() + { + var command = AiCommands.Create(); + + AssertNoWhitespaceAliases(command); + } + + [Fact] + public void BuildRootCommand_IncludesAiSubcommand() + { + var rootCommand = Program.BuildRootCommand(); + + Assert.Contains(rootCommand.Subcommands, c => c.Name == "ai"); + } + + private static void AssertNoWhitespaceAliases(Command command) + { + foreach (var option in command.Options) + { + Assert.False( + option.Name.Any(char.IsWhiteSpace), + $"Option name contains whitespace: \"{option.Name}\" in command '{command.Name}'"); + + foreach (var alias in option.Aliases) + { + Assert.False( + alias.Any(char.IsWhiteSpace), + $"Option alias contains whitespace: \"{alias}\" in command '{command.Name}'"); + } + } + + foreach (var subcommand in command.Subcommands) + { + AssertNoWhitespaceAliases(subcommand); + } + } +} diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs new file mode 100644 index 000000000..0b6402d2b --- /dev/null +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs @@ -0,0 +1,208 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Maui.Cli.Ai; +using Xunit; + +namespace Microsoft.Maui.Cli.UnitTests; + +public class MarketplaceClientTests +{ + [Fact] + public void ParseFrontmatter_ValidFrontmatter_ExtractsNameAndDescription() + { + var content = """ + --- + name: test-skill + description: A test skill for MAUI development + --- + # Test Skill + Some content here. + """; + + var (name, description) = MarketplaceClient.ParseFrontmatter(content); + + Assert.Equal("test-skill", name); + Assert.Equal("A test skill for MAUI development", description); + } + + [Fact] + public void ParseFrontmatter_QuotedValues_StripsQuotes() + { + var content = """ + --- + name: "quoted-skill" + description: 'A quoted description' + --- + """; + + var (name, description) = MarketplaceClient.ParseFrontmatter(content); + + Assert.Equal("quoted-skill", name); + Assert.Equal("A quoted description", description); + } + + [Fact] + public void ParseFrontmatter_NoFrontmatter_ReturnsNulls() + { + var content = """ + # Just a markdown file + No frontmatter here. + """; + + var (name, description) = MarketplaceClient.ParseFrontmatter(content); + + Assert.Null(name); + Assert.Null(description); + } + + [Fact] + public void ParseFrontmatter_EmptyContent_ReturnsNulls() + { + var (name, description) = MarketplaceClient.ParseFrontmatter(""); + + Assert.Null(name); + Assert.Null(description); + } + + [Fact] + public void ParseFrontmatter_NoClosingDelimiter_ReturnsNulls() + { + var content = """ + --- + name: incomplete + description: No closing delimiter + """; + + var (name, description) = MarketplaceClient.ParseFrontmatter(content); + + Assert.Null(name); + Assert.Null(description); + } + + [Fact] + public void ParseFrontmatter_OnlyNamePresent_ReturnsNameWithNullDescription() + { + var content = """ + --- + name: name-only + --- + """; + + var (name, description) = MarketplaceClient.ParseFrontmatter(content); + + Assert.Equal("name-only", name); + Assert.Null(description); + } + + [Fact] + public void ParseFrontmatter_OnlyDescriptionPresent_ReturnsDescriptionWithNullName() + { + var content = """ + --- + description: description-only + --- + """; + + var (name, description) = MarketplaceClient.ParseFrontmatter(content); + + Assert.Null(name); + Assert.Equal("description-only", description); + } + + [Fact] + public void ParseFrontmatter_LeadingWhitespace_StillParses() + { + var content = " \n\n---\nname: whitespace-skill\ndescription: Has leading whitespace\n---\n"; + + var (name, description) = MarketplaceClient.ParseFrontmatter(content); + + Assert.Equal("whitespace-skill", name); + Assert.Equal("Has leading whitespace", description); + } + + [Fact] + public void ParseFrontmatter_ExtraFieldsIgnored() + { + var content = """ + --- + name: my-skill + version: 1.0 + author: test + description: A skill with extra fields + tags: [a, b, c] + --- + """; + + var (name, description) = MarketplaceClient.ParseFrontmatter(content); + + Assert.Equal("my-skill", name); + Assert.Equal("A skill with extra fields", description); + } + + [Fact] + public void ParseFrontmatter_CaseInsensitiveKeys() + { + var content = """ + --- + Name: CasedSkill + Description: Case insensitive keys + --- + """; + + var (name, description) = MarketplaceClient.ParseFrontmatter(content); + + Assert.Equal("CasedSkill", name); + Assert.Equal("Case insensitive keys", description); + } + + [Theory] + [InlineData("---\nname: a\ndescription: b\n---", "a", "b")] + [InlineData("---\nname: spaced \ndescription: also spaced \n---", "spaced", "also spaced")] + [InlineData("---\nname: \"double-quoted\"\ndescription: 'single-quoted'\n---", "double-quoted", "single-quoted")] + public void ParseFrontmatter_VariousFormats(string content, string expectedName, string expectedDescription) + { + var (name, description) = MarketplaceClient.ParseFrontmatter(content); + + Assert.Equal(expectedName, name); + Assert.Equal(expectedDescription, description); + } + + [Fact] + public void ParseFrontmatter_BlockScalarIndicator_TreatsAsPlainText() + { + // The >- YAML block scalar indicator is treated as a plain value + // since ParseFrontmatter uses simple string operations + var content = """ + --- + name: block-skill + description: >- + --- + """; + + var (name, description) = MarketplaceClient.ParseFrontmatter(content); + + Assert.Equal("block-skill", name); + // The >- is treated as the value since the parser uses simple line-by-line parsing + Assert.NotNull(description); + } + + [Fact] + public void ParseFrontmatter_ContentAfterFrontmatter_IsIgnored() + { + var content = """ + --- + name: frontmatter-skill + description: Only frontmatter matters + --- + # Heading + name: this-should-not-be-parsed + description: Neither should this + """; + + var (name, description) = MarketplaceClient.ParseFrontmatter(content); + + Assert.Equal("frontmatter-skill", name); + Assert.Equal("Only frontmatter matters", description); + } +} diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs new file mode 100644 index 000000000..ca146dca9 --- /dev/null +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs @@ -0,0 +1,245 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json.Nodes; +using Microsoft.Maui.Cli.Ai; +using Microsoft.Maui.Cli.Ai.Models; +using Xunit; + +namespace Microsoft.Maui.Cli.UnitTests; + +public class McpConfiguratorTests : IDisposable +{ + private readonly string _tempDir; + + public McpConfiguratorTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, recursive: true); + } + + [Fact] + public async Task ConfigureAsync_CreatesNewConfigFile_WhenNoneExists() + { + var configDir = Path.Combine(_tempDir, ".claude"); + Directory.CreateDirectory(configDir); + var configPath = Path.Combine(configDir, "mcp.json"); + + var env = new DetectedEnvironment + { + Kind = AgentEnvironmentKind.Claude, + McpConfigPath = configPath, + SkillsDirectory = Path.Combine(_tempDir, ".claude", "skills") + }; + + var result = await McpConfigurator.ConfigureAsync(env, _tempDir); + + Assert.True(result); + Assert.True(File.Exists(configPath)); + + var json = JsonNode.Parse(await File.ReadAllTextAsync(configPath)); + var server = json?["mcpServers"]?["maui-devflow"]; + Assert.NotNull(server); + Assert.Equal("maui", server["command"]?.GetValue()); + } + + [Fact] + public async Task ConfigureAsync_ServerEntryHasCorrectArgs() + { + var configDir = Path.Combine(_tempDir, ".claude"); + Directory.CreateDirectory(configDir); + var configPath = Path.Combine(configDir, "mcp.json"); + + var env = new DetectedEnvironment + { + Kind = AgentEnvironmentKind.Claude, + McpConfigPath = configPath, + SkillsDirectory = Path.Combine(_tempDir, ".claude", "skills") + }; + + await McpConfigurator.ConfigureAsync(env, _tempDir); + + var json = JsonNode.Parse(await File.ReadAllTextAsync(configPath)); + var args = json?["mcpServers"]?["maui-devflow"]?["args"]?.AsArray(); + Assert.NotNull(args); + Assert.Equal(2, args.Count); + Assert.Equal("devflow", args[0]?.GetValue()); + Assert.Equal("mcp", args[1]?.GetValue()); + } + + [Fact] + public async Task ConfigureAsync_MergesIntoExistingConfig() + { + var configDir = Path.Combine(_tempDir, ".vscode"); + Directory.CreateDirectory(configDir); + var configPath = Path.Combine(configDir, "mcp.json"); + + // Write an existing config with another server entry + var existing = new JsonObject + { + ["mcpServers"] = new JsonObject + { + ["other-server"] = new JsonObject + { + ["command"] = "other", + ["args"] = new JsonArray("arg1") + } + } + }; + await File.WriteAllTextAsync(configPath, existing.ToJsonString()); + + var env = new DetectedEnvironment + { + Kind = AgentEnvironmentKind.VsCode, + McpConfigPath = configPath, + SkillsDirectory = Path.Combine(_tempDir, ".github", "skills") + }; + + var result = await McpConfigurator.ConfigureAsync(env, _tempDir); + + Assert.True(result); + var json = JsonNode.Parse(await File.ReadAllTextAsync(configPath)); + var servers = json?["mcpServers"]?.AsObject(); + Assert.NotNull(servers); + + // Both entries should exist + Assert.NotNull(servers["other-server"]); + Assert.NotNull(servers["maui-devflow"]); + } + + [Fact] + public async Task ConfigureAsync_Idempotent_DoesNotDuplicateEntry() + { + var configDir = Path.Combine(_tempDir, ".claude"); + Directory.CreateDirectory(configDir); + var configPath = Path.Combine(configDir, "mcp.json"); + + var env = new DetectedEnvironment + { + Kind = AgentEnvironmentKind.Claude, + McpConfigPath = configPath, + SkillsDirectory = Path.Combine(_tempDir, ".claude", "skills") + }; + + // Configure twice + await McpConfigurator.ConfigureAsync(env, _tempDir); + var contentAfterFirst = await File.ReadAllTextAsync(configPath); + + await McpConfigurator.ConfigureAsync(env, _tempDir); + var contentAfterSecond = await File.ReadAllTextAsync(configPath); + + // File should not change on second run (entry already exists) + Assert.Equal(contentAfterFirst, contentAfterSecond); + } + + [Fact] + public async Task ConfigureAsync_OpenCode_UsesNestedMcpServersKey() + { + var configDir = Path.Combine(_tempDir, ".opencode"); + Directory.CreateDirectory(configDir); + var configPath = Path.Combine(configDir, "config.json"); + + var env = new DetectedEnvironment + { + Kind = AgentEnvironmentKind.OpenCode, + McpConfigPath = configPath, + SkillsDirectory = Path.Combine(_tempDir, ".opencode", "skills") + }; + + var result = await McpConfigurator.ConfigureAsync(env, _tempDir); + + Assert.True(result); + var json = JsonNode.Parse(await File.ReadAllTextAsync(configPath)); + var server = json?["mcp"]?["servers"]?["maui-devflow"]; + Assert.NotNull(server); + Assert.Equal("maui", server["command"]?.GetValue()); + } + + [Fact] + public async Task ConfigureAsync_OpenCode_MergesIntoExistingConfig() + { + var configDir = Path.Combine(_tempDir, ".opencode"); + Directory.CreateDirectory(configDir); + var configPath = Path.Combine(configDir, "config.json"); + + // OpenCode uses "mcp" -> "servers" structure + var existing = new JsonObject + { + ["mcp"] = new JsonObject + { + ["servers"] = new JsonObject + { + ["existing-server"] = new JsonObject + { + ["command"] = "existing" + } + } + } + }; + await File.WriteAllTextAsync(configPath, existing.ToJsonString()); + + var env = new DetectedEnvironment + { + Kind = AgentEnvironmentKind.OpenCode, + McpConfigPath = configPath, + SkillsDirectory = Path.Combine(_tempDir, ".opencode", "skills") + }; + + await McpConfigurator.ConfigureAsync(env, _tempDir); + + var json = JsonNode.Parse(await File.ReadAllTextAsync(configPath)); + var servers = json?["mcp"]?["servers"]?.AsObject(); + Assert.NotNull(servers); + Assert.NotNull(servers["existing-server"]); + Assert.NotNull(servers["maui-devflow"]); + } + + [Fact] + public async Task ConfigureAsync_CreatesConfigDirectory_WhenMissing() + { + // Config directory does not exist yet + var configDir = Path.Combine(_tempDir, "new-env", ".claude"); + var configPath = Path.Combine(configDir, "mcp.json"); + + var env = new DetectedEnvironment + { + Kind = AgentEnvironmentKind.Claude, + McpConfigPath = configPath, + SkillsDirectory = Path.Combine(_tempDir, "new-env", ".claude", "skills") + }; + + var result = await McpConfigurator.ConfigureAsync(env, _tempDir); + + Assert.True(result); + Assert.True(File.Exists(configPath)); + } + + [Fact] + public async Task ConfigureAsync_ReturnsTrue_WhenEntryAlreadyExists() + { + var configDir = Path.Combine(_tempDir, ".claude"); + Directory.CreateDirectory(configDir); + var configPath = Path.Combine(configDir, "mcp.json"); + + var env = new DetectedEnvironment + { + Kind = AgentEnvironmentKind.Claude, + McpConfigPath = configPath, + SkillsDirectory = Path.Combine(_tempDir, ".claude", "skills") + }; + + // First call creates the entry + var first = await McpConfigurator.ConfigureAsync(env, _tempDir); + Assert.True(first); + + // Second call should also return true (already configured) + var second = await McpConfigurator.ConfigureAsync(env, _tempDir); + Assert.True(second); + } +} diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillVersionStoreTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillVersionStoreTests.cs new file mode 100644 index 000000000..607e420f4 --- /dev/null +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillVersionStoreTests.cs @@ -0,0 +1,192 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Maui.Cli.Ai; +using Microsoft.Maui.Cli.Ai.Models; +using Xunit; + +namespace Microsoft.Maui.Cli.UnitTests; + +public class SkillVersionStoreTests : IDisposable +{ + private readonly string _tempDir; + + public SkillVersionStoreTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, recursive: true); + } + + [Fact] + public async Task WriteAsync_CreatesSkillVersionFile() + { + var skillDir = Path.Combine(_tempDir, "test-skill"); + var version = new InstalledSkillVersion + { + Name = "test-skill", + Commit = "abc123", + Branch = "main", + UpdatedAt = "2025-01-01T00:00:00Z", + Source = "dotnet/maui-labs", + PluginPath = ".github/plugin/maui" + }; + + await SkillVersionStore.WriteAsync(skillDir, version); + + Assert.True(File.Exists(Path.Combine(skillDir, ".skill-version"))); + } + + [Fact] + public async Task WriteAsync_CreatesDirectoryIfNotExists() + { + var skillDir = Path.Combine(_tempDir, "nested", "dir", "test-skill"); + var version = new InstalledSkillVersion { Name = "test-skill", Commit = "abc123" }; + + await SkillVersionStore.WriteAsync(skillDir, version); + + Assert.True(Directory.Exists(skillDir)); + Assert.True(File.Exists(Path.Combine(skillDir, ".skill-version"))); + } + + [Fact] + public async Task ReadAsync_NonExistentDirectory_ReturnsNull() + { + var nonExistent = Path.Combine(_tempDir, "does-not-exist"); + + var result = await SkillVersionStore.ReadAsync(nonExistent); + + Assert.Null(result); + } + + [Fact] + public async Task ReadAsync_NoVersionFile_ReturnsNull() + { + // Directory exists but no .skill-version file + var result = await SkillVersionStore.ReadAsync(_tempDir); + + Assert.Null(result); + } + + [Fact] + public async Task RoundTrip_WriteAndRead_ReturnsSameValues() + { + var skillDir = Path.Combine(_tempDir, "roundtrip-skill"); + var version = new InstalledSkillVersion + { + Name = "my-skill", + Commit = "def456", + Branch = "develop", + UpdatedAt = "2025-06-15T12:30:00Z", + Source = "dotnet/maui-labs", + PluginPath = ".github/plugin/maui" + }; + + await SkillVersionStore.WriteAsync(skillDir, version); + var result = await SkillVersionStore.ReadAsync(skillDir); + + Assert.NotNull(result); + Assert.Equal("my-skill", result.Name); + Assert.Equal("def456", result.Commit); + Assert.Equal("develop", result.Branch); + Assert.Equal("2025-06-15T12:30:00Z", result.UpdatedAt); + Assert.Equal("dotnet/maui-labs", result.Source); + Assert.Equal(".github/plugin/maui", result.PluginPath); + } + + [Fact] + public async Task ReadAsync_LegacyFormat_ReturnsPartialData() + { + // Legacy format only has commit, updatedAt, branch (no name/source/pluginPath) + var skillDir = Path.Combine(_tempDir, "legacy-skill"); + Directory.CreateDirectory(skillDir); + var legacyJson = """ + { + "commit": "old123", + "updatedAt": "2024-01-01T00:00:00Z", + "branch": "main" + } + """; + await File.WriteAllTextAsync(Path.Combine(skillDir, ".skill-version"), legacyJson); + + var result = await SkillVersionStore.ReadAsync(skillDir); + + Assert.NotNull(result); + Assert.Equal("old123", result.Commit); + Assert.Equal("2024-01-01T00:00:00Z", result.UpdatedAt); + Assert.Equal("main", result.Branch); + Assert.Null(result.Name); + Assert.Null(result.Source); + Assert.Null(result.PluginPath); + } + + [Fact] + public async Task ReadAsync_CorruptedJson_ReturnsNull() + { + var skillDir = Path.Combine(_tempDir, "corrupted-skill"); + Directory.CreateDirectory(skillDir); + await File.WriteAllTextAsync( + Path.Combine(skillDir, ".skill-version"), + "this is not valid json {{{}}}"); + + var result = await SkillVersionStore.ReadAsync(skillDir); + + Assert.Null(result); + } + + [Fact] + public async Task ReadAsync_EmptyFile_ReturnsNull() + { + var skillDir = Path.Combine(_tempDir, "empty-skill"); + Directory.CreateDirectory(skillDir); + await File.WriteAllTextAsync(Path.Combine(skillDir, ".skill-version"), ""); + + var result = await SkillVersionStore.ReadAsync(skillDir); + + Assert.Null(result); + } + + [Fact] + public async Task WriteAsync_ProducesIndentedJson() + { + var skillDir = Path.Combine(_tempDir, "indented-skill"); + var version = new InstalledSkillVersion + { + Name = "test-skill", + Commit = "abc123" + }; + + await SkillVersionStore.WriteAsync(skillDir, version); + var content = await File.ReadAllTextAsync(Path.Combine(skillDir, ".skill-version")); + + // Indented JSON contains newlines and spaces + Assert.Contains("\n", content); + Assert.Contains(" ", content); + } + + [Fact] + public async Task WriteAsync_NullProperties_AreOmitted() + { + var skillDir = Path.Combine(_tempDir, "nulls-skill"); + var version = new InstalledSkillVersion + { + Name = "test-skill", + Commit = "abc123" + // Branch, UpdatedAt, Source, PluginPath are null + }; + + await SkillVersionStore.WriteAsync(skillDir, version); + var content = await File.ReadAllTextAsync(Path.Combine(skillDir, ".skill-version")); + + Assert.Contains("name", content); + Assert.Contains("commit", content); + Assert.DoesNotContain("branch", content); + Assert.DoesNotContain("source", content); + Assert.DoesNotContain("pluginPath", content); + } +} diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/AgentEnvironmentDetector.cs b/src/Cli/Microsoft.Maui.Cli/Ai/AgentEnvironmentDetector.cs new file mode 100644 index 000000000..ad1654cb6 --- /dev/null +++ b/src/Cli/Microsoft.Maui.Cli/Ai/AgentEnvironmentDetector.cs @@ -0,0 +1,116 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Maui.Cli.Ai.Models; + +namespace Microsoft.Maui.Cli.Ai; + +/// +/// Detects agent environments by scanning from the working directory up to the +/// Git repository root for known configuration directories. +/// +internal static class AgentEnvironmentDetector +{ + /// + /// Scans from up to the Git root for known + /// agent environments (.claude/, .vscode/, .opencode/) and checks for + /// Copilot CLI at the user level (~/.copilot/). + /// + /// Directory to start scanning from. + /// List of detected environments (may be empty). + public static List Detect(string workingDir) + { + var environments = new List(); + var gitRoot = FindGitRoot(workingDir); + var searchRoot = gitRoot ?? workingDir; + var foundKinds = new HashSet(); + + var current = new DirectoryInfo(workingDir); + var rootFullPath = gitRoot is not null ? Path.GetFullPath(gitRoot) : null; + + while (current is not null) + { + var dir = current.FullName; + + if (!foundKinds.Contains(AgentEnvironmentKind.Claude) && + Directory.Exists(Path.Combine(dir, ".claude"))) + { + foundKinds.Add(AgentEnvironmentKind.Claude); + environments.Add(new DetectedEnvironment + { + Kind = AgentEnvironmentKind.Claude, + SkillsDirectory = Path.Combine(dir, ".claude", "skills"), + McpConfigPath = Path.Combine(dir, ".claude", "mcp.json"), + McpConfigExists = File.Exists(Path.Combine(dir, ".claude", "mcp.json")) + }); + } + + if (!foundKinds.Contains(AgentEnvironmentKind.VsCode) && + Directory.Exists(Path.Combine(dir, ".vscode"))) + { + foundKinds.Add(AgentEnvironmentKind.VsCode); + environments.Add(new DetectedEnvironment + { + Kind = AgentEnvironmentKind.VsCode, + SkillsDirectory = Path.Combine(dir, ".github", "skills"), + McpConfigPath = Path.Combine(dir, ".vscode", "mcp.json"), + McpConfigExists = File.Exists(Path.Combine(dir, ".vscode", "mcp.json")) + }); + } + + if (!foundKinds.Contains(AgentEnvironmentKind.OpenCode) && + Directory.Exists(Path.Combine(dir, ".opencode"))) + { + foundKinds.Add(AgentEnvironmentKind.OpenCode); + environments.Add(new DetectedEnvironment + { + Kind = AgentEnvironmentKind.OpenCode, + SkillsDirectory = Path.Combine(dir, ".opencode", "skills"), + McpConfigPath = Path.Combine(dir, ".opencode", "config.json"), + McpConfigExists = File.Exists(Path.Combine(dir, ".opencode", "config.json")) + }); + } + + // Stop at the Git root. + if (rootFullPath is not null && + string.Equals(current.FullName, rootFullPath, StringComparison.Ordinal)) + break; + + current = current.Parent; + } + + // Copilot CLI is detected at the user level. + var userHome = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var copilotDir = Path.Combine(userHome, ".copilot"); + if (Directory.Exists(copilotDir)) + { + var mcpPath = Path.Combine(copilotDir, "mcp.json"); + environments.Add(new DetectedEnvironment + { + Kind = AgentEnvironmentKind.CopilotCli, + SkillsDirectory = Path.Combine(searchRoot, ".github", "skills"), + McpConfigPath = mcpPath, + McpConfigExists = File.Exists(mcpPath) + }); + } + + return environments; + } + + /// + /// Walks up from looking for a .git directory. + /// + private static string? FindGitRoot(string startDir) + { + var current = new DirectoryInfo(startDir); + while (current is not null) + { + if (Directory.Exists(Path.Combine(current.FullName, ".git"))) + return current.FullName; + + current = current.Parent; + } + + return null; + } +} diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/AiJsonContext.cs b/src/Cli/Microsoft.Maui.Cli/Ai/AiJsonContext.cs new file mode 100644 index 000000000..b7b73dac9 --- /dev/null +++ b/src/Cli/Microsoft.Maui.Cli/Ai/AiJsonContext.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json.Serialization; +using Microsoft.Maui.Cli.Ai.Models; + +namespace Microsoft.Maui.Cli.Ai; + +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(MarketplaceManifest))] +[JsonSerializable(typeof(PluginEntry))] +[JsonSerializable(typeof(PluginEntry[]))] +[JsonSerializable(typeof(PluginManifest))] +[JsonSerializable(typeof(SkillInfo))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(DetectedEnvironment))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(InstalledSkillVersion))] +internal sealed partial class AiJsonContext : JsonSerializerContext; diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs b/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs new file mode 100644 index 000000000..f6e20aafe --- /dev/null +++ b/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs @@ -0,0 +1,330 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Maui.Cli.Ai.Models; + +namespace Microsoft.Maui.Cli.Ai; + +/// +/// HTTP client for marketplace operations. Fetches manifests, enumerates skills, +/// and downloads skill files from a GitHub-hosted marketplace repository. +/// +internal static class MarketplaceClient +{ + private const string GitHubApiBase = "https://api.github.com"; + private const string GitHubRawBase = "https://raw.githubusercontent.com"; + + /// + /// Fetches and deserializes the marketplace.json manifest from the repository. + /// + /// Configured (caller manages lifetime). + /// Repository in "owner/repo" format. + /// Branch name to read from. + /// The deserialized manifest, or null on failure. + public static async Task GetMarketplaceAsync(HttpClient http, string repo, string branch) + { + var url = $"{GitHubRawBase}/{repo}/{branch}/.github/plugin/marketplace.json"; + var json = await FetchStringAsync(http, url).ConfigureAwait(false); + if (json is null) + return null; + + return JsonSerializer.Deserialize(json, AiJsonContext.Default.MarketplaceManifest); + } + + /// + /// Fetches and deserializes the plugin.json manifest for a specific plugin. + /// + /// Configured . + /// Repository in "owner/repo" format. + /// Branch name to read from. + /// Repository-relative path to the plugin directory. + /// The deserialized plugin manifest, or null on failure. + public static async Task GetPluginAsync(HttpClient http, string repo, string branch, string pluginSourcePath) + { + var path = NormalizePath($"{pluginSourcePath}/plugin.json"); + var url = $"{GitHubRawBase}/{repo}/{branch}/{path}"; + var json = await FetchStringAsync(http, url).ConfigureAwait(false); + if (json is null) + return null; + + return JsonSerializer.Deserialize(json, AiJsonContext.Default.PluginManifest); + } + + /// + /// Discovers all skills within a plugin by enumerating the repository tree + /// and parsing SKILL.md frontmatter. + /// + /// Configured . + /// Repository in "owner/repo" format. + /// Branch name to read from. + /// The plugin manifest whose skills to discover. + /// Repository-relative path to the plugin directory. + /// List of discovered skills (empty on failure). + public static async Task> GetSkillsAsync( + HttpClient http, string repo, string branch, PluginManifest plugin, string pluginSourcePath) + { + var skills = new List(); + + // Resolve the branch to a tree SHA, then fetch the full recursive tree. + var treeSha = await ResolveTreeShaAsync(http, repo, branch).ConfigureAwait(false); + if (treeSha is null) + return skills; + + var treeUrl = $"{GitHubApiBase}/repos/{repo}/git/trees/{treeSha}?recursive=1"; + var treeJson = await FetchStringAsync(http, treeUrl).ConfigureAwait(false); + if (treeJson is null) + return skills; + + var treeNode = JsonNode.Parse(treeJson); + var treeArray = treeNode?["tree"]?.AsArray(); + if (treeArray is null) + return skills; + + // Collect all tree entries as (path, type) pairs. + var entries = new List<(string Path, string Type)>(); + foreach (var entry in treeArray) + { + var entryPath = entry?["path"]?.GetValue(); + var entryType = entry?["type"]?.GetValue(); + if (entryPath is not null && entryType is not null) + entries.Add((entryPath, entryType)); + } + + var normalizedPluginPath = NormalizePath(pluginSourcePath); + + foreach (var skillGlob in plugin.Skills) + { + var basePath = NormalizePath($"{normalizedPluginPath}/{skillGlob}"); + var prefix = basePath + "/"; + + // Find SKILL.md files exactly one level below the base path. + var skillMdPaths = new List(); + foreach (var (entryPath, entryType) in entries) + { + if (entryType != "blob" || !entryPath.StartsWith(prefix, StringComparison.Ordinal)) + continue; + + var relative = entryPath[prefix.Length..]; + var slashIndex = relative.IndexOf('/'); + if (slashIndex > 0 && relative[(slashIndex + 1)..].Equals("SKILL.md", StringComparison.OrdinalIgnoreCase)) + skillMdPaths.Add(entryPath); + } + + foreach (var skillMdPath in skillMdPaths) + { + var skillDir = skillMdPath[..skillMdPath.LastIndexOf('/')]; + var skillDirPrefix = skillDir + "/"; + + // Collect all blob entries in this skill directory. + var skillFiles = entries + .Where(e => e.Type == "blob" && e.Path.StartsWith(skillDirPrefix, StringComparison.Ordinal)) + .Select(e => e.Path) + .ToList(); + + var (name, description) = await ParseSkillFrontmatterAsync(http, repo, branch, skillMdPath).ConfigureAwait(false); + var dirName = skillDir.Contains('/') + ? skillDir[(skillDir.LastIndexOf('/') + 1)..] + : skillDir; + + skills.Add(new SkillInfo + { + Name = name ?? dirName, + Description = description, + PluginName = plugin.Name, + RemotePath = skillDir, + Files = skillFiles + }); + } + } + + return skills; + } + + /// + /// Downloads all files for a skill to the specified destination directory. + /// + /// Configured . + /// Skill whose files to download. + /// Local directory to write files into. + /// Repository in "owner/repo" format. + /// Branch name to read from. + /// Count of files successfully downloaded. + public static async Task DownloadSkillFilesAsync( + HttpClient http, SkillInfo skill, string destDir, string repo, string branch) + { + var count = 0; + + foreach (var filePath in skill.Files) + { + // Compute the path relative to the skill's remote root. + var relativePath = filePath; + var remotePrefix = skill.RemotePath + "/"; + if (filePath.StartsWith(remotePrefix, StringComparison.Ordinal)) + relativePath = filePath[remotePrefix.Length..]; + + var url = $"{GitHubRawBase}/{repo}/{branch}/{filePath}"; + var content = await FetchBytesAsync(http, url).ConfigureAwait(false); + if (content is null) + continue; + + var destPath = Path.Combine(destDir, relativePath.Replace('/', Path.DirectorySeparatorChar)); + var destFileDir = Path.GetDirectoryName(destPath); + if (destFileDir is not null) + Directory.CreateDirectory(destFileDir); + + await File.WriteAllBytesAsync(destPath, content).ConfigureAwait(false); + count++; + } + + return count; + } + + /// + /// Resolves the latest commit SHA that touched a specific path on the given branch. + /// + /// Configured . + /// Repository in "owner/repo" format. + /// Branch name. + /// Repository-relative path to query. + /// The commit SHA, or null on failure. + public static async Task GetRemoteCommitShaAsync(HttpClient http, string repo, string branch, string path) + { + var url = $"{GitHubApiBase}/repos/{repo}/commits?sha={Uri.EscapeDataString(branch)}&path={Uri.EscapeDataString(path)}&per_page=1"; + var json = await FetchStringAsync(http, url).ConfigureAwait(false); + if (json is null) + return null; + + var array = JsonNode.Parse(json)?.AsArray(); + return array is { Count: > 0 } ? array[0]?["sha"]?.GetValue() : null; + } + + /// + /// Resolves the tree SHA for the given branch by fetching the latest commit. + /// + private static async Task ResolveTreeShaAsync(HttpClient http, string repo, string branch) + { + var url = $"{GitHubApiBase}/repos/{repo}/commits/{Uri.EscapeDataString(branch)}"; + var json = await FetchStringAsync(http, url).ConfigureAwait(false); + if (json is null) + return null; + + var node = JsonNode.Parse(json); + return node?["commit"]?["tree"]?["sha"]?.GetValue(); + } + + /// + /// Downloads and parses the YAML frontmatter from a SKILL.md file. + /// + private static async Task<(string? Name, string? Description)> ParseSkillFrontmatterAsync( + HttpClient http, string repo, string branch, string skillMdPath) + { + var url = $"{GitHubRawBase}/{repo}/{branch}/{skillMdPath}"; + var content = await FetchStringAsync(http, url).ConfigureAwait(false); + if (content is null) + return (null, null); + + return ParseFrontmatter(content); + } + + /// + /// Extracts name and description from YAML frontmatter delimited by ---. + /// Uses simple string operations — no YAML library required. + /// + internal static (string? Name, string? Description) ParseFrontmatter(string content) + { + string? name = null; + string? description = null; + + var trimmed = content.TrimStart(); + if (!trimmed.StartsWith("---", StringComparison.Ordinal)) + return (name, description); + + var endIndex = trimmed.IndexOf("---", 3, StringComparison.Ordinal); + if (endIndex < 0) + return (name, description); + + var frontmatter = trimmed[3..endIndex]; + foreach (var line in frontmatter.Split('\n')) + { + var trimmedLine = line.Trim(); + if (trimmedLine.StartsWith("name:", StringComparison.OrdinalIgnoreCase)) + name = StripYamlValue(trimmedLine["name:".Length..]); + else if (trimmedLine.StartsWith("description:", StringComparison.OrdinalIgnoreCase)) + description = StripYamlValue(trimmedLine["description:".Length..]); + } + + return (name, description); + } + + /// + /// Strips surrounding whitespace and optional quotes from a YAML value. + /// + private static string StripYamlValue(string raw) + { + var value = raw.Trim(); + if (value.Length >= 2 && + ((value[0] == '"' && value[^1] == '"') || + (value[0] == '\'' && value[^1] == '\''))) + { + value = value[1..^1]; + } + + return value; + } + + /// + /// Normalizes a repository-relative path by removing ./ prefixes, + /// collapsing double slashes, and trimming trailing slashes. + /// + private static string NormalizePath(string path) + { + var normalized = path.Replace('\\', '/'); + while (normalized.StartsWith("./", StringComparison.Ordinal)) + normalized = normalized[2..]; + while (normalized.Contains("//")) + normalized = normalized.Replace("//", "/"); + return normalized.TrimEnd('/'); + } + + private static async Task FetchStringAsync(HttpClient http, string url) + { + try + { + using var response = await http.GetAsync(url).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + return null; + + return await response.Content.ReadAsStringAsync().ConfigureAwait(false); + } + catch (HttpRequestException) + { + return null; + } + catch (TaskCanceledException) + { + return null; + } + } + + private static async Task FetchBytesAsync(HttpClient http, string url) + { + try + { + using var response = await http.GetAsync(url).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + return null; + + return await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false); + } + catch (HttpRequestException) + { + return null; + } + catch (TaskCanceledException) + { + return null; + } + } +} diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs b/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs new file mode 100644 index 000000000..b72dff7d2 --- /dev/null +++ b/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs @@ -0,0 +1,126 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Maui.Cli.Ai.Models; + +namespace Microsoft.Maui.Cli.Ai; + +/// +/// Writes MCP server configuration for agent environments. Performs a +/// schema-preserving merge so existing configuration entries are retained. +/// +internal static class McpConfigurator +{ + private const string ServerName = "maui-devflow"; + + /// + /// Ensures the maui-devflow MCP server entry exists in the + /// environment's MCP configuration file. Creates the file if it does not exist. + /// The operation is idempotent — it does nothing if the entry already exists. + /// + /// Target agent environment. + /// Absolute path to the project root directory. + /// true if the configuration is in place; false on failure. + public static async Task ConfigureAsync(DetectedEnvironment env, string projectRoot) + { + try + { + var configPath = env.McpConfigPath; + + JsonObject root; + if (File.Exists(configPath)) + { + var existingJson = await File.ReadAllTextAsync(configPath).ConfigureAwait(false); + root = JsonNode.Parse(existingJson)?.AsObject() ?? new JsonObject(); + } + else + { + root = new JsonObject(); + } + + var serverEntry = new JsonObject + { + ["command"] = "maui", + ["args"] = new JsonArray("devflow", "mcp") + }; + + bool alreadyConfigured; + if (env.Kind == AgentEnvironmentKind.OpenCode) + { + alreadyConfigured = EnsureOpenCodeEntry(root, serverEntry); + } + else + { + alreadyConfigured = EnsureStandardEntry(root, serverEntry); + } + + if (alreadyConfigured) + return true; + + // Ensure the config directory exists before writing. + var configDir = Path.GetDirectoryName(configPath); + if (configDir is not null) + Directory.CreateDirectory(configDir); + + var options = new JsonSerializerOptions { WriteIndented = true }; + await File.WriteAllTextAsync(configPath, root.ToJsonString(options)).ConfigureAwait(false); + + return true; + } + catch (IOException) + { + return false; + } + catch (JsonException) + { + return false; + } + } + + /// + /// Adds the server entry under the standard mcpServers key used by + /// Claude, VS Code, and Copilot CLI. + /// + /// true if the entry already exists (no changes needed). + private static bool EnsureStandardEntry(JsonObject root, JsonObject serverEntry) + { + if (root["mcpServers"] is not JsonObject mcpServers) + { + mcpServers = new JsonObject(); + root["mcpServers"] = mcpServers; + } + + if (mcpServers[ServerName] is not null) + return true; + + mcpServers[ServerName] = serverEntry; + return false; + } + + /// + /// Adds the server entry under the OpenCode-specific mcp.servers key. + /// + /// true if the entry already exists (no changes needed). + private static bool EnsureOpenCodeEntry(JsonObject root, JsonObject serverEntry) + { + if (root["mcp"] is not JsonObject mcp) + { + mcp = new JsonObject(); + root["mcp"] = mcp; + } + + if (mcp["servers"] is not JsonObject servers) + { + servers = new JsonObject(); + mcp["servers"] = servers; + } + + if (servers[ServerName] is not null) + return true; + + servers[ServerName] = serverEntry; + return false; + } +} diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/Models/AgentEnvironment.cs b/src/Cli/Microsoft.Maui.Cli/Ai/Models/AgentEnvironment.cs new file mode 100644 index 000000000..3174c4b35 --- /dev/null +++ b/src/Cli/Microsoft.Maui.Cli/Ai/Models/AgentEnvironment.cs @@ -0,0 +1,49 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Maui.Cli.Ai.Models; + +/// +/// Supported agent environment kinds that can be detected on the local machine. +/// +internal enum AgentEnvironmentKind +{ + /// Claude Code agent. + Claude, + + /// VS Code with GitHub Copilot extension. + VsCode, + + /// GitHub Copilot CLI agent. + CopilotCli, + + /// OpenCode agent. + OpenCode +} + +/// +/// Represents a detected agent environment and its configuration paths for +/// skill installation and MCP server registration. +/// +internal sealed class DetectedEnvironment +{ + /// + /// Kind of agent environment detected. + /// + public AgentEnvironmentKind Kind { get; set; } + + /// + /// Absolute path to the directory where skills should be installed. + /// + public string SkillsDirectory { get; set; } = string.Empty; + + /// + /// Absolute path to the MCP configuration file. + /// + public string McpConfigPath { get; set; } = string.Empty; + + /// + /// Whether the MCP configuration file already exists on disk. + /// + public bool McpConfigExists { get; set; } +} diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/Models/InstalledSkillVersion.cs b/src/Cli/Microsoft.Maui.Cli/Ai/Models/InstalledSkillVersion.cs new file mode 100644 index 000000000..b5fb2a40b --- /dev/null +++ b/src/Cli/Microsoft.Maui.Cli/Ai/Models/InstalledSkillVersion.cs @@ -0,0 +1,42 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Maui.Cli.Ai.Models; + +/// +/// Represents the content of a .skill-version JSON file that tracks +/// installed skill metadata. Backward compatible with the legacy format +/// that only contains commit, updatedAt, and branch fields. +/// +internal sealed class InstalledSkillVersion +{ + /// + /// Name of the installed skill. + /// + public string? Name { get; set; } + + /// + /// Git commit SHA at the time of installation. + /// + public string? Commit { get; set; } + + /// + /// Branch from which the skill was installed. + /// + public string? Branch { get; set; } + + /// + /// ISO 8601 timestamp of when the skill was last updated. + /// + public string? UpdatedAt { get; set; } + + /// + /// Source repository identifier (e.g. "owner/repo"). + /// + public string? Source { get; set; } + + /// + /// Relative path to the plugin within the marketplace repository. + /// + public string? PluginPath { get; set; } +} diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/Models/MarketplaceManifest.cs b/src/Cli/Microsoft.Maui.Cli/Ai/Models/MarketplaceManifest.cs new file mode 100644 index 000000000..a537f47f3 --- /dev/null +++ b/src/Cli/Microsoft.Maui.Cli/Ai/Models/MarketplaceManifest.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Maui.Cli.Ai.Models; + +/// +/// Represents the top-level marketplace.json manifest that lists available plugins. +/// +internal sealed class MarketplaceManifest +{ + /// + /// Name of the marketplace repository. + /// + public string Name { get; set; } = string.Empty; + + /// + /// List of plugin entries available in the marketplace. + /// + public PluginEntry[] Plugins { get; set; } = []; +} + +/// +/// Represents a single plugin entry in the marketplace manifest. +/// +internal sealed class PluginEntry +{ + /// + /// Name of the plugin. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Relative path to the plugin source directory within the marketplace repository. + /// + public string Source { get; set; } = string.Empty; + + /// + /// Optional description of the plugin. + /// + public string? Description { get; set; } +} diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/Models/PluginManifest.cs b/src/Cli/Microsoft.Maui.Cli/Ai/Models/PluginManifest.cs new file mode 100644 index 000000000..17c84ce33 --- /dev/null +++ b/src/Cli/Microsoft.Maui.Cli/Ai/Models/PluginManifest.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Maui.Cli.Ai.Models; + +/// +/// Represents the plugin.json manifest for a single plugin in the marketplace. +/// +internal sealed class PluginManifest +{ + /// + /// Name of the plugin. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Version of the plugin (e.g. "0.1.0"). + /// + public string? Version { get; set; } + + /// + /// Optional description of the plugin. + /// + public string? Description { get; set; } + + /// + /// Relative paths to skill directories or skill definition globs. + /// + public string[] Skills { get; set; } = []; +} diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/Models/SkillInfo.cs b/src/Cli/Microsoft.Maui.Cli/Ai/Models/SkillInfo.cs new file mode 100644 index 000000000..42fb805d0 --- /dev/null +++ b/src/Cli/Microsoft.Maui.Cli/Ai/Models/SkillInfo.cs @@ -0,0 +1,36 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Maui.Cli.Ai.Models; + +/// +/// Represents a discovered skill from the marketplace, including metadata +/// parsed from SKILL.md frontmatter and the list of associated files. +/// +internal sealed class SkillInfo +{ + /// + /// Name of the skill (from SKILL.md frontmatter or directory name). + /// + public string Name { get; set; } = string.Empty; + + /// + /// Optional description of the skill parsed from SKILL.md frontmatter. + /// + public string? Description { get; set; } + + /// + /// Name of the parent plugin that contains this skill. + /// + public string PluginName { get; set; } = string.Empty; + + /// + /// Repository-root-relative path of the skill directory in the marketplace. + /// + public string RemotePath { get; set; } = string.Empty; + + /// + /// List of repository-root-relative file paths that belong to this skill. + /// + public List Files { get; set; } = []; +} diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs b/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs new file mode 100644 index 000000000..6990fe068 --- /dev/null +++ b/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs @@ -0,0 +1,71 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Maui.Cli.Ai.Models; + +namespace Microsoft.Maui.Cli.Ai; + +/// +/// Orchestrates skill installation by downloading files from the marketplace, +/// creating the local directory structure, and writing version metadata. +/// +internal static class SkillInstaller +{ + /// + /// Installs a skill into the target environment directory. + /// + /// Configured (caller manages lifetime). + /// Skill to install. + /// Target agent environment. + /// Absolute path to the project root directory. + /// Repository in "owner/repo" format. + /// Branch name to install from. + /// When true, overwrite an existing installation. + /// + /// A tuple of (filesInstalled, installPath) where filesInstalled is the number + /// of files written and installPath is the absolute path to the skill directory. + /// Returns (0, installPath) if the skill is already installed and is false. + /// + public static async Task<(int FilesInstalled, string InstallPath)> InstallSkillAsync( + HttpClient http, + SkillInfo skill, + DetectedEnvironment env, + string projectRoot, + string repo, + string branch, + bool force) + { + var installPath = Path.Combine(env.SkillsDirectory, skill.Name); + + // Skip if already installed and not forcing. + if (!force) + { + var existing = await SkillVersionStore.ReadAsync(installPath).ConfigureAwait(false); + if (existing is not null) + return (0, installPath); + } + + Directory.CreateDirectory(installPath); + + var filesInstalled = await MarketplaceClient.DownloadSkillFilesAsync( + http, skill, installPath, repo, branch).ConfigureAwait(false); + + // Resolve the latest commit SHA for version tracking. + var commitSha = await MarketplaceClient.GetRemoteCommitShaAsync( + http, repo, branch, skill.RemotePath).ConfigureAwait(false); + + var version = new InstalledSkillVersion + { + Name = skill.Name, + Commit = commitSha, + Branch = branch, + UpdatedAt = DateTime.UtcNow.ToString("o"), + Source = repo, + PluginPath = skill.RemotePath + }; + + await SkillVersionStore.WriteAsync(installPath, version).ConfigureAwait(false); + + return (filesInstalled, installPath); + } +} diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/SkillVersionStore.cs b/src/Cli/Microsoft.Maui.Cli/Ai/SkillVersionStore.cs new file mode 100644 index 000000000..661cffb14 --- /dev/null +++ b/src/Cli/Microsoft.Maui.Cli/Ai/SkillVersionStore.cs @@ -0,0 +1,63 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Maui.Cli.Ai.Models; + +namespace Microsoft.Maui.Cli.Ai; + +/// +/// Reads and writes .skill-version JSON files that track installed skill metadata. +/// Backward compatible with the legacy format containing only commit, updatedAt, and branch. +/// +internal static class SkillVersionStore +{ + private const string VersionFileName = ".skill-version"; + + /// + /// Reads the .skill-version file from the specified skill directory. + /// + /// Absolute path to the skill installation directory. + /// The deserialized version info, or null if not found or unreadable. + public static async Task ReadAsync(string skillDir) + { + var path = Path.Combine(skillDir, VersionFileName); + if (!File.Exists(path)) + return null; + + try + { + var json = await File.ReadAllTextAsync(path).ConfigureAwait(false); + return JsonSerializer.Deserialize(json, AiJsonContext.Default.InstalledSkillVersion); + } + catch (JsonException) + { + return null; + } + catch (IOException) + { + return null; + } + } + + /// + /// Writes a .skill-version file to the specified skill directory. + /// Creates the directory if it does not exist. + /// + /// Absolute path to the skill installation directory. + /// Version metadata to persist. + public static async Task WriteAsync(string skillDir, InstalledSkillVersion version) + { + Directory.CreateDirectory(skillDir); + var path = Path.Combine(skillDir, VersionFileName); + + var json = JsonSerializer.Serialize(version, AiJsonContext.Default.InstalledSkillVersion); + + // Re-format as indented JSON for human readability. + var node = JsonNode.Parse(json); + var indented = node?.ToJsonString(new JsonSerializerOptions { WriteIndented = true }) ?? json; + + await File.WriteAllTextAsync(path, indented).ConfigureAwait(false); + } +} diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Add.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Add.cs new file mode 100644 index 000000000..e18ee828f --- /dev/null +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Add.cs @@ -0,0 +1,183 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using System.CommandLine.Parsing; +using System.Text.Json.Nodes; +using Microsoft.Maui.Cli.Ai; +using Microsoft.Maui.Cli.Ai.Models; +using Microsoft.Maui.Cli.Output; +using Spectre.Console; + +namespace Microsoft.Maui.Cli.Commands; + +public static partial class AiCommands +{ + /// + /// Creates the maui ai add <skill> command that installs a specific skill by name. + /// + static Command CreateAddCommand() + { + var skillArg = new Argument("skill") { Description = "Name of the skill to add" }; + + var envOption = new Option("--env") + { + Description = "Target only specific environments (repeatable, e.g. Claude, VsCode)", + AllowMultipleArgumentsPerToken = true + }; + + var command = new Command("add", "Add a specific AI agent skill by name") + { + skillArg, + CreateRepoOption(), + CreateBranchOption(), + CreateForceOption(), + new Option("--no-mcp") { Description = "Skip MCP server configuration" }, + envOption + }; + + command.SetAction(async (ParseResult parseResult, CancellationToken ct) => + { + var formatter = Program.GetFormatter(parseResult); + var useJson = parseResult.GetValue(GlobalOptions.JsonOption); + var isCi = parseResult.GetValue(GlobalOptions.CiOption); + var dryRun = parseResult.GetValue(GlobalOptions.DryRunOption); + var skillName = parseResult.GetValue(skillArg) ?? string.Empty; + var repo = parseResult.GetOption("repo") ?? DefaultRepo; + var branch = parseResult.GetOption("branch") ?? DefaultBranch; + var force = parseResult.GetOption("force"); + var noMcp = parseResult.GetOption("no-mcp"); + var envFilter = parseResult.GetOption("env"); + + if (string.IsNullOrWhiteSpace(skillName)) + { + formatter.WriteError(new Exception("Skill name is required. Usage: maui ai add ")); + return 1; + } + + try + { + using var http = CreateGitHubHttpClient(); + + // Fetch marketplace to find the requested skill + List allSkills; + if (!useJson && formatter is SpectreOutputFormatter spectre) + { + allSkills = await spectre.StatusAsync("Fetching marketplace...", async () => + await FetchAllSkillsAsync(http, repo, branch)); + } + else + { + allSkills = await FetchAllSkillsAsync(http, repo, branch); + } + + var skill = allSkills.FirstOrDefault(s => + string.Equals(s.Name, skillName, StringComparison.OrdinalIgnoreCase)); + + if (skill is null) + { + formatter.WriteError(new Exception( + $"Skill '{skillName}' not found in the marketplace. Run 'maui ai list' to see available skills.")); + return 1; + } + + // Detect environments + var workingDir = Directory.GetCurrentDirectory(); + var environments = AgentEnvironmentDetector.Detect(workingDir); + + if (envFilter is { Length: > 0 }) + { + environments = environments + .Where(e => envFilter.Any(f => + string.Equals(f, e.Kind.ToString(), StringComparison.OrdinalIgnoreCase))) + .ToList(); + } + + if (environments.Count == 0) + { + formatter.WriteWarning("No agent environments detected. Run 'maui ai init' to set up environments first."); + return 1; + } + + if (dryRun) + { + formatter.WriteInfo($"[Dry run] Would install skill '{skill.Name}' to:"); + formatter.WriteTable( + environments, + ("Environment", e => e.Kind.ToString()), + ("Path", e => Path.Combine(e.SkillsDirectory, skill.Name))); + return 0; + } + + // Confirm unless --force, --ci, or --json + if (!force && !isCi && !useJson) + { + formatter.WriteInfo($"Will install '{skill.Name}' ({skill.Files.Count} files) to {environments.Count} environment(s)."); + if (!AnsiConsole.Confirm("Proceed?", defaultValue: true)) + { + formatter.WriteInfo("Installation cancelled."); + return 0; + } + } + + // Install + var results = new List<(string Env, int Files, string Path)>(); + + foreach (var env in environments) + { + var (filesInstalled, installPath) = await SkillInstaller.InstallSkillAsync( + http, skill, env, workingDir, repo, branch, force); + + results.Add((env.Kind.ToString(), filesInstalled, installPath)); + + if (filesInstalled > 0) + formatter.WriteSuccess($"Installed {skill.Name} → {env.Kind} ({filesInstalled} files)"); + else + formatter.WriteInfo($"Skipped {skill.Name} → {env.Kind} (already installed, use --force to overwrite)"); + } + + // Configure MCP + if (!noMcp) + { + foreach (var env in environments) + { + var ok = await McpConfigurator.ConfigureAsync(env, workingDir); + if (ok) + formatter.WriteSuccess($"MCP configured for {env.Kind}"); + else + formatter.WriteWarning($"Could not configure MCP for {env.Kind}"); + } + } + + if (useJson) + { + var jsonResult = new JsonObject + { + ["status"] = "success", + ["skill"] = skill.Name, + ["installations"] = new JsonArray(results.Select(r => (JsonNode)new JsonObject + { + ["environment"] = r.Env, + ["files"] = r.Files, + ["path"] = r.Path + }).ToArray()) + }; + formatter.Write(jsonResult); + } + + return 0; + } + catch (HttpRequestException ex) + { + formatter.WriteError(new Exception($"Network error: {ex.Message}. Check your connection or set GITHUB_TOKEN for higher rate limits.")); + return 1; + } + catch (Exception ex) + { + return Program.HandleCommandException(formatter, ex); + } + }); + + return command; + } +} diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs new file mode 100644 index 000000000..bfb0faa78 --- /dev/null +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs @@ -0,0 +1,312 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using System.CommandLine.Parsing; +using System.Text.Json.Nodes; +using Microsoft.Maui.Cli.Ai; +using Microsoft.Maui.Cli.Ai.Models; +using Microsoft.Maui.Cli.Output; +using Spectre.Console; + +namespace Microsoft.Maui.Cli.Commands; + +public static partial class AiCommands +{ + /// + /// Creates the maui ai init command that bootstraps agent skill installation. + /// + static Command CreateInitCommand() + { + var skillOption = new Option("--skill") + { + Description = "Install only specific skills (repeatable)", + AllowMultipleArgumentsPerToken = true + }; + + var envOption = new Option("--env") + { + Description = "Target only specific environments (repeatable, e.g. Claude, VsCode)", + AllowMultipleArgumentsPerToken = true + }; + + var command = new Command("init", "Initialize AI agent skills for MAUI development") + { + CreateRepoOption(), + CreateBranchOption(), + CreateForceOption(), + new Option("--no-mcp") { Description = "Skip MCP server configuration" }, + skillOption, + envOption + }; + + command.SetAction(async (ParseResult parseResult, CancellationToken ct) => + { + var formatter = Program.GetFormatter(parseResult); + var useJson = parseResult.GetValue(GlobalOptions.JsonOption); + var isCi = parseResult.GetValue(GlobalOptions.CiOption); + var dryRun = parseResult.GetValue(GlobalOptions.DryRunOption); + var repo = parseResult.GetOption("repo") ?? DefaultRepo; + var branch = parseResult.GetOption("branch") ?? DefaultBranch; + var force = parseResult.GetOption("force"); + var noMcp = parseResult.GetOption("no-mcp"); + var skillFilter = parseResult.GetOption("skill"); + var envFilter = parseResult.GetOption("env"); + + try + { + using var http = CreateGitHubHttpClient(); + + // Step 1: Fetch marketplace and discover skills + List allSkills; + if (!useJson && formatter is SpectreOutputFormatter spectre) + { + allSkills = await spectre.StatusAsync("Fetching marketplace...", async () => + await FetchAllSkillsAsync(http, repo, branch)); + } + else + { + allSkills = await FetchAllSkillsAsync(http, repo, branch); + } + + if (allSkills.Count == 0) + { + formatter.WriteWarning("No skills found in the marketplace."); + return 1; + } + + // Step 2: Detect agent environments + var workingDir = Directory.GetCurrentDirectory(); + var environments = AgentEnvironmentDetector.Detect(workingDir); + + // Filter environments if --env specified + if (envFilter is { Length: > 0 }) + { + environments = environments + .Where(e => envFilter.Any(f => + string.Equals(f, e.Kind.ToString(), StringComparison.OrdinalIgnoreCase))) + .ToList(); + } + + if (environments.Count == 0) + { + if (useJson) + { + formatter.WriteWarning("No agent environments detected."); + return 1; + } + + if (isCi) + { + // In CI mode, create .claude/ by default + var claudeDir = Path.Combine(workingDir, ".claude"); + Directory.CreateDirectory(claudeDir); + environments = AgentEnvironmentDetector.Detect(workingDir); + } + else + { + // Prompt user to create a default environment + formatter.WriteWarning("No agent environments detected."); + var create = AnsiConsole.Confirm( + "Create [cyan].claude/[/] directory for Claude Code?", defaultValue: true); + if (!create) + { + formatter.WriteInfo("No environments to configure. Exiting."); + return 0; + } + + var claudeDir = Path.Combine(workingDir, ".claude"); + Directory.CreateDirectory(claudeDir); + environments = AgentEnvironmentDetector.Detect(workingDir); + } + } + + formatter.WriteInfo($"Detected {environments.Count} environment(s): {string.Join(", ", environments.Select(e => e.Kind))}"); + + // Step 3: Select skills + List selectedSkills; + if (skillFilter is { Length: > 0 }) + { + selectedSkills = allSkills + .Where(s => skillFilter.Any(f => + string.Equals(f, s.Name, StringComparison.OrdinalIgnoreCase))) + .ToList(); + + if (selectedSkills.Count == 0) + { + formatter.WriteError(new Exception($"No skills matched filter: {string.Join(", ", skillFilter)}")); + return 1; + } + } + else if (useJson || isCi) + { + selectedSkills = allSkills; + } + else + { + var prompt = new MultiSelectionPrompt() + .Title("Select skills to install:") + .PageSize(15) + .InstructionsText("[grey](Press [blue][/] to toggle, [green][/] to accept)[/]"); + + foreach (var skill in allSkills) + { + var label = string.IsNullOrEmpty(skill.Description) + ? skill.Name + : $"{skill.Name} - {skill.Description}"; + prompt.AddChoice(label); + } + + // Pre-select all by default + prompt.AddChoices(Array.Empty()); + foreach (var skill in allSkills) + { + var label = string.IsNullOrEmpty(skill.Description) + ? skill.Name + : $"{skill.Name} - {skill.Description}"; + prompt.Select(label); + } + + var selected = AnsiConsole.Prompt(prompt); + selectedSkills = allSkills + .Where(s => + { + var label = string.IsNullOrEmpty(s.Description) + ? s.Name + : $"{s.Name} - {s.Description}"; + return selected.Contains(label); + }) + .ToList(); + + if (selectedSkills.Count == 0) + { + formatter.WriteInfo("No skills selected."); + return 0; + } + } + + // Step 4: Confirmation + if (!force && !isCi && !useJson) + { + formatter.WriteInfo($"Will install {selectedSkills.Count} skill(s) to {environments.Count} environment(s):"); + formatter.WriteTable( + selectedSkills, + ("Skill", s => s.Name), + ("Plugin", s => s.PluginName), + ("Files", s => s.Files.Count.ToString())); + + if (!AnsiConsole.Confirm("Proceed with installation?", defaultValue: true)) + { + formatter.WriteInfo("Installation cancelled."); + return 0; + } + } + + if (dryRun) + { + formatter.WriteInfo("[Dry run] Would install the following skills:"); + formatter.WriteTable( + from s in selectedSkills + from e in environments + select new { s.Name, Env = e.Kind.ToString(), Path = Path.Combine(e.SkillsDirectory, s.Name) }, + ("Skill", x => x.Name), + ("Environment", x => x.Env), + ("Path", x => x.Path)); + return 0; + } + + // Step 5: Install skills + var results = new List<(string Skill, string Env, int Files, string Path)>(); + + foreach (var env in environments) + { + foreach (var skill in selectedSkills) + { + var (filesInstalled, installPath) = await SkillInstaller.InstallSkillAsync( + http, skill, env, workingDir, repo, branch, force); + + results.Add((skill.Name, env.Kind.ToString(), filesInstalled, installPath)); + + if (filesInstalled > 0) + formatter.WriteSuccess($"Installed {skill.Name} → {env.Kind} ({filesInstalled} files)"); + else + formatter.WriteInfo($"Skipped {skill.Name} → {env.Kind} (already installed)"); + } + } + + // Step 6: Configure MCP + if (!noMcp) + { + foreach (var env in environments) + { + var ok = await McpConfigurator.ConfigureAsync(env, workingDir); + if (ok) + formatter.WriteSuccess($"MCP configured for {env.Kind}"); + else + formatter.WriteWarning($"Could not configure MCP for {env.Kind}"); + } + } + + // Step 7: Summary + formatter.WriteTable( + results, + ("Skill", r => r.Skill), + ("Environment", r => r.Env), + ("Files", r => r.Files > 0 ? r.Files.ToString() : "skipped"), + ("Path", r => r.Path)); + + if (useJson) + { + var jsonResult = new JsonObject + { + ["status"] = "success", + ["skills"] = new JsonArray(results.Select(r => (JsonNode)new JsonObject + { + ["skill"] = r.Skill, + ["environment"] = r.Env, + ["files"] = r.Files, + ["path"] = r.Path + }).ToArray()) + }; + formatter.Write(jsonResult); + } + + return 0; + } + catch (HttpRequestException ex) + { + formatter.WriteError(new Exception($"Network error: {ex.Message}. Check your connection or set GITHUB_TOKEN for higher rate limits.")); + return 1; + } + catch (Exception ex) + { + return Program.HandleCommandException(formatter, ex); + } + }); + + return command; + } + + /// + /// Fetches all skills from every plugin listed in the marketplace manifest. + /// + static async Task> FetchAllSkillsAsync(HttpClient http, string repo, string branch) + { + var marketplace = await MarketplaceClient.GetMarketplaceAsync(http, repo, branch); + if (marketplace is null) + return []; + + var allSkills = new List(); + foreach (var pluginEntry in marketplace.Plugins) + { + var plugin = await MarketplaceClient.GetPluginAsync(http, repo, branch, pluginEntry.Source); + if (plugin is null) + continue; + + var skills = await MarketplaceClient.GetSkillsAsync(http, repo, branch, plugin, pluginEntry.Source); + allSkills.AddRange(skills); + } + + return allSkills; + } +} diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.List.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.List.cs new file mode 100644 index 000000000..1163a74e0 --- /dev/null +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.List.cs @@ -0,0 +1,88 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using System.CommandLine.Parsing; +using System.Text.Json.Nodes; +using Microsoft.Maui.Cli.Ai; +using Microsoft.Maui.Cli.Output; + +namespace Microsoft.Maui.Cli.Commands; + +public static partial class AiCommands +{ + /// + /// Creates the maui ai list command that shows available marketplace skills. + /// + static Command CreateListCommand() + { + var command = new Command("list", "List available AI agent skills from the marketplace") + { + CreateRepoOption(), + CreateBranchOption() + }; + + command.SetAction(async (ParseResult parseResult, CancellationToken ct) => + { + var formatter = Program.GetFormatter(parseResult); + var useJson = parseResult.GetValue(GlobalOptions.JsonOption); + var repo = parseResult.GetOption("repo") ?? DefaultRepo; + var branch = parseResult.GetOption("branch") ?? DefaultBranch; + + try + { + using var http = CreateGitHubHttpClient(); + + List allSkills; + if (!useJson && formatter is SpectreOutputFormatter spectre) + { + allSkills = await spectre.StatusAsync("Fetching marketplace...", async () => + await FetchAllSkillsAsync(http, repo, branch)); + } + else + { + allSkills = await FetchAllSkillsAsync(http, repo, branch); + } + + if (allSkills.Count == 0) + { + formatter.WriteWarning("No skills found in the marketplace."); + return 1; + } + + if (useJson) + { + var jsonArray = new JsonArray(allSkills.Select(s => (JsonNode)new JsonObject + { + ["skill"] = s.Name, + ["plugin"] = s.PluginName, + ["description"] = s.Description ?? "", + ["files"] = s.Files.Count + }).ToArray()); + formatter.Write(jsonArray); + } + else + { + formatter.WriteTable( + allSkills, + ("Skill", s => s.Name), + ("Plugin", s => s.PluginName), + ("Description", s => s.Description ?? "")); + } + + return 0; + } + catch (HttpRequestException ex) + { + formatter.WriteError(new Exception($"Network error: {ex.Message}. Check your connection or set GITHUB_TOKEN for higher rate limits.")); + return 1; + } + catch (Exception ex) + { + return Program.HandleCommandException(formatter, ex); + } + }); + + return command; + } +} diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Status.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Status.cs new file mode 100644 index 000000000..ed0ada273 --- /dev/null +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Status.cs @@ -0,0 +1,135 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using System.CommandLine.Parsing; +using System.Text.Json.Nodes; +using Microsoft.Maui.Cli.Ai; +using Microsoft.Maui.Cli.Ai.Models; +using Microsoft.Maui.Cli.Output; + +namespace Microsoft.Maui.Cli.Commands; + +public static partial class AiCommands +{ + /// + /// Creates the maui ai status command that shows installed skill status and checks for updates. + /// + static Command CreateStatusCommand() + { + var command = new Command("status", "Show status of installed AI agent skills") + { + CreateRepoOption(), + CreateBranchOption(), + new Option("--check-updates") { Description = "Check remote repository for available updates" } + }; + + command.SetAction(async (ParseResult parseResult, CancellationToken ct) => + { + var formatter = Program.GetFormatter(parseResult); + var useJson = parseResult.GetValue(GlobalOptions.JsonOption); + var repo = parseResult.GetOption("repo") ?? DefaultRepo; + var branch = parseResult.GetOption("branch") ?? DefaultBranch; + var checkUpdates = parseResult.GetOption("check-updates"); + + try + { + var workingDir = Directory.GetCurrentDirectory(); + var environments = AgentEnvironmentDetector.Detect(workingDir); + + if (environments.Count == 0) + { + formatter.WriteWarning("No agent environments detected. Run 'maui ai init' first."); + return 1; + } + + using var http = checkUpdates ? CreateGitHubHttpClient() : null; + + var rows = new List<(string Skill, string Env, string Installed, string Status)>(); + + foreach (var env in environments) + { + if (!Directory.Exists(env.SkillsDirectory)) + continue; + + foreach (var skillDir in Directory.GetDirectories(env.SkillsDirectory)) + { + var skillName = Path.GetFileName(skillDir); + var version = await SkillVersionStore.ReadAsync(skillDir); + + if (version is null) + { + rows.Add((skillName, env.Kind.ToString(), "Unknown", "Unknown")); + continue; + } + + var installed = version.UpdatedAt is not null + ? DateTime.Parse(version.UpdatedAt).ToLocalTime().ToString("yyyy-MM-dd HH:mm") + : "Unknown"; + + var status = "Installed"; + + if (checkUpdates && http is not null && version.PluginPath is not null) + { + var remoteSha = await MarketplaceClient.GetRemoteCommitShaAsync( + http, repo, branch, version.PluginPath); + + if (remoteSha is not null && version.Commit is not null) + { + status = string.Equals(remoteSha, version.Commit, StringComparison.OrdinalIgnoreCase) + ? "Up to date" + : "Update available"; + } + else + { + status = "Up to date"; + } + } + + rows.Add((skillName, env.Kind.ToString(), installed, status)); + } + } + + if (rows.Count == 0) + { + formatter.WriteInfo("No skills installed. Run 'maui ai init' to get started."); + return 0; + } + + if (useJson) + { + var jsonArray = new JsonArray(rows.Select(r => (JsonNode)new JsonObject + { + ["skill"] = r.Skill, + ["environment"] = r.Env, + ["installed"] = r.Installed, + ["status"] = r.Status + }).ToArray()); + formatter.Write(jsonArray); + } + else + { + formatter.WriteTable( + rows, + ("Skill", r => r.Skill), + ("Environment", r => r.Env), + ("Installed", r => r.Installed), + ("Status", r => r.Status)); + } + + return 0; + } + catch (HttpRequestException ex) + { + formatter.WriteError(new Exception($"Network error: {ex.Message}. Check your connection or set GITHUB_TOKEN for higher rate limits.")); + return 1; + } + catch (Exception ex) + { + return Program.HandleCommandException(formatter, ex); + } + }); + + return command; + } +} diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs new file mode 100644 index 000000000..3c0172cc1 --- /dev/null +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs @@ -0,0 +1,194 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; +using System.CommandLine.Parsing; +using System.Text.Json.Nodes; +using Microsoft.Maui.Cli.Ai; +using Microsoft.Maui.Cli.Ai.Models; +using Microsoft.Maui.Cli.Output; +using Spectre.Console; + +namespace Microsoft.Maui.Cli.Commands; + +public static partial class AiCommands +{ + /// + /// Creates the maui ai update command that updates installed skills to the latest version. + /// + static Command CreateUpdateCommand() + { + var skillOption = new Option("--skill") + { + Description = "Update only specific skills (repeatable)", + AllowMultipleArgumentsPerToken = true + }; + + var command = new Command("update", "Update installed AI agent skills to the latest version") + { + CreateRepoOption(), + CreateBranchOption(), + CreateForceOption(), + skillOption + }; + + command.SetAction(async (ParseResult parseResult, CancellationToken ct) => + { + var formatter = Program.GetFormatter(parseResult); + var useJson = parseResult.GetValue(GlobalOptions.JsonOption); + var isCi = parseResult.GetValue(GlobalOptions.CiOption); + var dryRun = parseResult.GetValue(GlobalOptions.DryRunOption); + var repo = parseResult.GetOption("repo") ?? DefaultRepo; + var branch = parseResult.GetOption("branch") ?? DefaultBranch; + var force = parseResult.GetOption("force"); + var skillFilter = parseResult.GetOption("skill"); + + try + { + var workingDir = Directory.GetCurrentDirectory(); + var environments = AgentEnvironmentDetector.Detect(workingDir); + + if (environments.Count == 0) + { + formatter.WriteWarning("No agent environments detected. Run 'maui ai init' first."); + return 1; + } + + using var http = CreateGitHubHttpClient(); + + // Scan installed skills and check for updates + var updatable = new List<(DetectedEnvironment Env, string SkillDir, string SkillName, InstalledSkillVersion Version)>(); + + foreach (var env in environments) + { + if (!Directory.Exists(env.SkillsDirectory)) + continue; + + foreach (var skillDir in Directory.GetDirectories(env.SkillsDirectory)) + { + var skillName = Path.GetFileName(skillDir); + + if (skillFilter is { Length: > 0 } && + !skillFilter.Any(f => string.Equals(f, skillName, StringComparison.OrdinalIgnoreCase))) + continue; + + var version = await SkillVersionStore.ReadAsync(skillDir); + if (version is null) + continue; + + // Check if update is available + if (version.PluginPath is not null) + { + var remoteSha = await MarketplaceClient.GetRemoteCommitShaAsync( + http, repo, branch, version.PluginPath); + + var needsUpdate = force || + remoteSha is null || + version.Commit is null || + !string.Equals(remoteSha, version.Commit, StringComparison.OrdinalIgnoreCase); + + if (needsUpdate) + updatable.Add((env, skillDir, skillName, version)); + } + } + } + + if (updatable.Count == 0) + { + formatter.WriteSuccess("All skills are up to date."); + return 0; + } + + formatter.WriteInfo($"Found {updatable.Count} skill(s) with updates available."); + + if (dryRun) + { + formatter.WriteInfo("[Dry run] Would update the following skills:"); + formatter.WriteTable( + updatable, + ("Skill", u => u.SkillName), + ("Environment", u => u.Env.Kind.ToString()), + ("Current Commit", u => (u.Version.Commit ?? "unknown")[..Math.Min(u.Version.Commit?.Length ?? 7, 7)]), + ("Path", u => u.SkillDir)); + return 0; + } + + // Confirm unless --force, --ci, or --json + if (!force && !isCi && !useJson) + { + formatter.WriteTable( + updatable, + ("Skill", u => u.SkillName), + ("Environment", u => u.Env.Kind.ToString()), + ("Path", u => u.SkillDir)); + + if (!AnsiConsole.Confirm("Proceed with update?", defaultValue: true)) + { + formatter.WriteInfo("Update cancelled."); + return 0; + } + } + + // Fetch marketplace to get skill metadata for re-download + List allSkills; + if (!useJson && formatter is SpectreOutputFormatter spectre) + { + allSkills = await spectre.StatusAsync("Fetching marketplace...", async () => + await FetchAllSkillsAsync(http, repo, branch)); + } + else + { + allSkills = await FetchAllSkillsAsync(http, repo, branch); + } + + var results = new List<(string Skill, string Env, int Files)>(); + + foreach (var (env, skillDir, skillName, _) in updatable) + { + var skillInfo = allSkills.FirstOrDefault(s => + string.Equals(s.Name, skillName, StringComparison.OrdinalIgnoreCase)); + + if (skillInfo is null) + { + formatter.WriteWarning($"Skill '{skillName}' not found in marketplace, skipping."); + continue; + } + + var (filesInstalled, _) = await SkillInstaller.InstallSkillAsync( + http, skillInfo, env, workingDir, repo, branch, force: true); + + results.Add((skillName, env.Kind.ToString(), filesInstalled)); + formatter.WriteSuccess($"Updated {skillName} → {env.Kind} ({filesInstalled} files)"); + } + + if (useJson) + { + var jsonResult = new JsonObject + { + ["status"] = "success", + ["updated"] = new JsonArray(results.Select(r => (JsonNode)new JsonObject + { + ["skill"] = r.Skill, + ["environment"] = r.Env, + ["files"] = r.Files + }).ToArray()) + }; + formatter.Write(jsonResult); + } + + return 0; + } + catch (HttpRequestException ex) + { + formatter.WriteError(new Exception($"Network error: {ex.Message}. Check your connection or set GITHUB_TOKEN for higher rate limits.")); + return 1; + } + catch (Exception ex) + { + return Program.HandleCommandException(formatter, ex); + } + }); + + return command; + } +} diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.cs new file mode 100644 index 000000000..d4a417ae9 --- /dev/null +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.cs @@ -0,0 +1,65 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CommandLine; + +namespace Microsoft.Maui.Cli.Commands; + +/// +/// Root command group for AI-assisted MAUI development: install and manage agent skills. +/// +public static partial class AiCommands +{ + private const string DefaultRepo = "dotnet/maui-labs"; + private const string DefaultBranch = "main"; + private const string DefaultMarketplacePath = ".github/plugin/marketplace.json"; + + public static Command Create() + { + var command = new Command("ai", "AI-assisted MAUI development: install and manage agent skills"); + command.Add(CreateInitCommand()); + command.Add(CreateListCommand()); + command.Add(CreateStatusCommand()); + command.Add(CreateUpdateCommand()); + command.Add(CreateAddCommand()); + return command; + } + + /// + /// Creates the shared --repo option used by multiple subcommands. + /// + static Option CreateRepoOption() => + new("--repo") { Description = "GitHub repository", DefaultValueFactory = _ => DefaultRepo }; + + /// + /// Creates the shared --branch / -b option used by multiple subcommands. + /// + static Option CreateBranchOption() => + new("--branch", "-b") { Description = "GitHub branch", DefaultValueFactory = _ => DefaultBranch }; + + /// + /// Creates the shared --force / -y option for skipping confirmation prompts. + /// + static Option CreateForceOption() => + new("--force", "-y") { Description = "Skip confirmation prompts" }; + + /// + /// Creates an configured for GitHub API access. + /// Respects the GITHUB_TOKEN environment variable for authentication. + /// + static HttpClient CreateGitHubHttpClient() + { + var http = new HttpClient(); + http.DefaultRequestHeaders.UserAgent.Add( + new System.Net.Http.Headers.ProductInfoHeaderValue("Microsoft.Maui.Cli", "1.0")); + http.DefaultRequestHeaders.Accept.Add( + new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + + var token = Environment.GetEnvironmentVariable("GITHUB_TOKEN"); + if (!string.IsNullOrEmpty(token)) + http.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + + return http; + } +} diff --git a/src/Cli/Microsoft.Maui.Cli/Program.cs b/src/Cli/Microsoft.Maui.Cli/Program.cs index 7325380b2..f32f42b01 100644 --- a/src/Cli/Microsoft.Maui.Cli/Program.cs +++ b/src/Cli/Microsoft.Maui.Cli/Program.cs @@ -110,6 +110,9 @@ internal static RootCommand BuildRootCommand() // DevFlow automation commands (maui devflow ...) rootCommand.Add(DevFlow.DevFlowCommands.CreateDevFlowCommand(GlobalOptions.JsonOption)); + // AI agent skill management (maui ai ...) + rootCommand.Add(Commands.AiCommands.Create()); + return rootCommand; } From 81732cf67ed8ea900c135d201ec28452e9b6a4af Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Fri, 17 Apr 2026 09:22:56 +0200 Subject: [PATCH 02/31] fix: Address review security, UX, and spec compliancefindings Security fixes: - Path traversal validation in DownloadSkillFilesAsync - Skill name sanitization (reject path chars and '..') 'as JsonObject' (prevents crash on non-object JSON) - Guard .skill-version write when 0 files downloaded Spec compliance: - Add Owner field to MarketplaceManifest (per marketplace.json spec) - Add Agents and LspServers fields to PluginManifest (per plugin.json spec) - Add StringOrArrayConverter for skills/agents (spec allows both) - Register MarketplaceOwner in AiJsonContext for AOT UX improvements: - Next-steps guidance after init completes - 'Installed' column in 'maui ai list' output - Proper singular/plural for 'environment' - Hide --repo/--branch options (developer-only) - MCP restart reminder after configuration - --force now skips env creation prompt (same as --ci) - Safe DateTime.TryParse for version timestamps Robustness: - CancellationToken propagated through all async service methods - Removed unused DefaultMarketplacePath constant Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../McpConfiguratorTests.cs | 20 +++---- .../Microsoft.Maui.Cli/Ai/AiJsonContext.cs | 1 + .../Ai/MarketplaceClient.cs | 54 +++++++++++-------- .../Microsoft.Maui.Cli/Ai/McpConfigurator.cs | 10 ++-- .../Ai/Models/MarketplaceManifest.cs | 16 ++++++ .../Ai/Models/PluginManifest.cs | 16 ++++++ .../Microsoft.Maui.Cli/Ai/SkillInstaller.cs | 23 ++++++-- .../Ai/SkillVersionStore.cs | 10 ++-- .../Ai/StringOrArrayConverter.cs | 48 +++++++++++++++++ .../Commands/AiCommands.Add.cs | 8 +-- .../Commands/AiCommands.Init.cs | 36 ++++++++----- .../Commands/AiCommands.List.cs | 30 +++++++++-- .../Commands/AiCommands.Status.cs | 7 +-- .../Commands/AiCommands.Update.cs | 10 ++-- .../Microsoft.Maui.Cli/Commands/AiCommands.cs | 5 +- 15 files changed, 215 insertions(+), 79 deletions(-) create mode 100644 src/Cli/Microsoft.Maui.Cli/Ai/StringOrArrayConverter.cs diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs index ca146dca9..1cf933e24 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs @@ -38,7 +38,7 @@ public async Task ConfigureAsync_CreatesNewConfigFile_WhenNoneExists() SkillsDirectory = Path.Combine(_tempDir, ".claude", "skills") }; - var result = await McpConfigurator.ConfigureAsync(env, _tempDir); + var result = await McpConfigurator.ConfigureAsync(env); Assert.True(result); Assert.True(File.Exists(configPath)); @@ -63,7 +63,7 @@ public async Task ConfigureAsync_ServerEntryHasCorrectArgs() SkillsDirectory = Path.Combine(_tempDir, ".claude", "skills") }; - await McpConfigurator.ConfigureAsync(env, _tempDir); + await McpConfigurator.ConfigureAsync(env); var json = JsonNode.Parse(await File.ReadAllTextAsync(configPath)); var args = json?["mcpServers"]?["maui-devflow"]?["args"]?.AsArray(); @@ -101,7 +101,7 @@ public async Task ConfigureAsync_MergesIntoExistingConfig() SkillsDirectory = Path.Combine(_tempDir, ".github", "skills") }; - var result = await McpConfigurator.ConfigureAsync(env, _tempDir); + var result = await McpConfigurator.ConfigureAsync(env); Assert.True(result); var json = JsonNode.Parse(await File.ReadAllTextAsync(configPath)); @@ -128,10 +128,10 @@ public async Task ConfigureAsync_Idempotent_DoesNotDuplicateEntry() }; // Configure twice - await McpConfigurator.ConfigureAsync(env, _tempDir); + await McpConfigurator.ConfigureAsync(env); var contentAfterFirst = await File.ReadAllTextAsync(configPath); - await McpConfigurator.ConfigureAsync(env, _tempDir); + await McpConfigurator.ConfigureAsync(env); var contentAfterSecond = await File.ReadAllTextAsync(configPath); // File should not change on second run (entry already exists) @@ -152,7 +152,7 @@ public async Task ConfigureAsync_OpenCode_UsesNestedMcpServersKey() SkillsDirectory = Path.Combine(_tempDir, ".opencode", "skills") }; - var result = await McpConfigurator.ConfigureAsync(env, _tempDir); + var result = await McpConfigurator.ConfigureAsync(env); Assert.True(result); var json = JsonNode.Parse(await File.ReadAllTextAsync(configPath)); @@ -191,7 +191,7 @@ public async Task ConfigureAsync_OpenCode_MergesIntoExistingConfig() SkillsDirectory = Path.Combine(_tempDir, ".opencode", "skills") }; - await McpConfigurator.ConfigureAsync(env, _tempDir); + await McpConfigurator.ConfigureAsync(env); var json = JsonNode.Parse(await File.ReadAllTextAsync(configPath)); var servers = json?["mcp"]?["servers"]?.AsObject(); @@ -214,7 +214,7 @@ public async Task ConfigureAsync_CreatesConfigDirectory_WhenMissing() SkillsDirectory = Path.Combine(_tempDir, "new-env", ".claude", "skills") }; - var result = await McpConfigurator.ConfigureAsync(env, _tempDir); + var result = await McpConfigurator.ConfigureAsync(env); Assert.True(result); Assert.True(File.Exists(configPath)); @@ -235,11 +235,11 @@ public async Task ConfigureAsync_ReturnsTrue_WhenEntryAlreadyExists() }; // First call creates the entry - var first = await McpConfigurator.ConfigureAsync(env, _tempDir); + var first = await McpConfigurator.ConfigureAsync(env); Assert.True(first); // Second call should also return true (already configured) - var second = await McpConfigurator.ConfigureAsync(env, _tempDir); + var second = await McpConfigurator.ConfigureAsync(env); Assert.True(second); } } diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/AiJsonContext.cs b/src/Cli/Microsoft.Maui.Cli/Ai/AiJsonContext.cs index b7b73dac9..85ff159cb 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/AiJsonContext.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/AiJsonContext.cs @@ -10,6 +10,7 @@ namespace Microsoft.Maui.Cli.Ai; PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] [JsonSerializable(typeof(MarketplaceManifest))] +[JsonSerializable(typeof(MarketplaceOwner))] [JsonSerializable(typeof(PluginEntry))] [JsonSerializable(typeof(PluginEntry[]))] [JsonSerializable(typeof(PluginManifest))] diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs b/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs index f6e20aafe..486b46774 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs @@ -23,10 +23,10 @@ internal static class MarketplaceClient /// Repository in "owner/repo" format. /// Branch name to read from. /// The deserialized manifest, or null on failure. - public static async Task GetMarketplaceAsync(HttpClient http, string repo, string branch) + public static async Task GetMarketplaceAsync(HttpClient http, string repo, string branch, CancellationToken ct = default) { var url = $"{GitHubRawBase}/{repo}/{branch}/.github/plugin/marketplace.json"; - var json = await FetchStringAsync(http, url).ConfigureAwait(false); + var json = await FetchStringAsync(http, url, ct).ConfigureAwait(false); if (json is null) return null; @@ -41,11 +41,11 @@ internal static class MarketplaceClient /// Branch name to read from. /// Repository-relative path to the plugin directory. /// The deserialized plugin manifest, or null on failure. - public static async Task GetPluginAsync(HttpClient http, string repo, string branch, string pluginSourcePath) + public static async Task GetPluginAsync(HttpClient http, string repo, string branch, string pluginSourcePath, CancellationToken ct = default) { var path = NormalizePath($"{pluginSourcePath}/plugin.json"); var url = $"{GitHubRawBase}/{repo}/{branch}/{path}"; - var json = await FetchStringAsync(http, url).ConfigureAwait(false); + var json = await FetchStringAsync(http, url, ct).ConfigureAwait(false); if (json is null) return null; @@ -63,17 +63,17 @@ internal static class MarketplaceClient /// Repository-relative path to the plugin directory. /// List of discovered skills (empty on failure). public static async Task> GetSkillsAsync( - HttpClient http, string repo, string branch, PluginManifest plugin, string pluginSourcePath) + HttpClient http, string repo, string branch, PluginManifest plugin, string pluginSourcePath, CancellationToken ct = default) { var skills = new List(); // Resolve the branch to a tree SHA, then fetch the full recursive tree. - var treeSha = await ResolveTreeShaAsync(http, repo, branch).ConfigureAwait(false); + var treeSha = await ResolveTreeShaAsync(http, repo, branch, ct).ConfigureAwait(false); if (treeSha is null) return skills; var treeUrl = $"{GitHubApiBase}/repos/{repo}/git/trees/{treeSha}?recursive=1"; - var treeJson = await FetchStringAsync(http, treeUrl).ConfigureAwait(false); + var treeJson = await FetchStringAsync(http, treeUrl, ct).ConfigureAwait(false); if (treeJson is null) return skills; @@ -123,7 +123,7 @@ public static async Task> GetSkillsAsync( .Select(e => e.Path) .ToList(); - var (name, description) = await ParseSkillFrontmatterAsync(http, repo, branch, skillMdPath).ConfigureAwait(false); + var (name, description) = await ParseSkillFrontmatterAsync(http, repo, branch, skillMdPath, ct).ConfigureAwait(false); var dirName = skillDir.Contains('/') ? skillDir[(skillDir.LastIndexOf('/') + 1)..] : skillDir; @@ -150,11 +150,13 @@ public static async Task> GetSkillsAsync( /// Local directory to write files into. /// Repository in "owner/repo" format. /// Branch name to read from. + /// Cancellation token. /// Count of files successfully downloaded. public static async Task DownloadSkillFilesAsync( - HttpClient http, SkillInfo skill, string destDir, string repo, string branch) + HttpClient http, SkillInfo skill, string destDir, string repo, string branch, CancellationToken ct = default) { var count = 0; + var fullBase = Path.GetFullPath(destDir) + Path.DirectorySeparatorChar; foreach (var filePath in skill.Files) { @@ -165,16 +167,22 @@ public static async Task DownloadSkillFilesAsync( relativePath = filePath[remotePrefix.Length..]; var url = $"{GitHubRawBase}/{repo}/{branch}/{filePath}"; - var content = await FetchBytesAsync(http, url).ConfigureAwait(false); + var content = await FetchBytesAsync(http, url, ct).ConfigureAwait(false); if (content is null) continue; var destPath = Path.Combine(destDir, relativePath.Replace('/', Path.DirectorySeparatorChar)); + + // Validate the resolved path stays under the destination directory. + var fullDest = Path.GetFullPath(destPath); + if (!fullDest.StartsWith(fullBase, StringComparison.Ordinal)) + continue; + var destFileDir = Path.GetDirectoryName(destPath); if (destFileDir is not null) Directory.CreateDirectory(destFileDir); - await File.WriteAllBytesAsync(destPath, content).ConfigureAwait(false); + await File.WriteAllBytesAsync(destPath, content, ct).ConfigureAwait(false); count++; } @@ -189,10 +197,10 @@ public static async Task DownloadSkillFilesAsync( /// Branch name. /// Repository-relative path to query. /// The commit SHA, or null on failure. - public static async Task GetRemoteCommitShaAsync(HttpClient http, string repo, string branch, string path) + public static async Task GetRemoteCommitShaAsync(HttpClient http, string repo, string branch, string path, CancellationToken ct = default) { var url = $"{GitHubApiBase}/repos/{repo}/commits?sha={Uri.EscapeDataString(branch)}&path={Uri.EscapeDataString(path)}&per_page=1"; - var json = await FetchStringAsync(http, url).ConfigureAwait(false); + var json = await FetchStringAsync(http, url, ct).ConfigureAwait(false); if (json is null) return null; @@ -203,10 +211,10 @@ public static async Task DownloadSkillFilesAsync( /// /// Resolves the tree SHA for the given branch by fetching the latest commit. /// - private static async Task ResolveTreeShaAsync(HttpClient http, string repo, string branch) + private static async Task ResolveTreeShaAsync(HttpClient http, string repo, string branch, CancellationToken ct = default) { var url = $"{GitHubApiBase}/repos/{repo}/commits/{Uri.EscapeDataString(branch)}"; - var json = await FetchStringAsync(http, url).ConfigureAwait(false); + var json = await FetchStringAsync(http, url, ct).ConfigureAwait(false); if (json is null) return null; @@ -218,10 +226,10 @@ public static async Task DownloadSkillFilesAsync( /// Downloads and parses the YAML frontmatter from a SKILL.md file. /// private static async Task<(string? Name, string? Description)> ParseSkillFrontmatterAsync( - HttpClient http, string repo, string branch, string skillMdPath) + HttpClient http, string repo, string branch, string skillMdPath, CancellationToken ct = default) { var url = $"{GitHubRawBase}/{repo}/{branch}/{skillMdPath}"; - var content = await FetchStringAsync(http, url).ConfigureAwait(false); + var content = await FetchStringAsync(http, url, ct).ConfigureAwait(false); if (content is null) return (null, null); @@ -288,15 +296,15 @@ private static string NormalizePath(string path) return normalized.TrimEnd('/'); } - private static async Task FetchStringAsync(HttpClient http, string url) + private static async Task FetchStringAsync(HttpClient http, string url, CancellationToken ct = default) { try { - using var response = await http.GetAsync(url).ConfigureAwait(false); + using var response = await http.GetAsync(url, ct).ConfigureAwait(false); if (!response.IsSuccessStatusCode) return null; - return await response.Content.ReadAsStringAsync().ConfigureAwait(false); + return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); } catch (HttpRequestException) { @@ -308,15 +316,15 @@ private static string NormalizePath(string path) } } - private static async Task FetchBytesAsync(HttpClient http, string url) + private static async Task FetchBytesAsync(HttpClient http, string url, CancellationToken ct = default) { try { - using var response = await http.GetAsync(url).ConfigureAwait(false); + using var response = await http.GetAsync(url, ct).ConfigureAwait(false); if (!response.IsSuccessStatusCode) return null; - return await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false); + return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false); } catch (HttpRequestException) { diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs b/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs index b72dff7d2..6560901b5 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs @@ -21,9 +21,9 @@ internal static class McpConfigurator /// The operation is idempotent — it does nothing if the entry already exists. /// /// Target agent environment. - /// Absolute path to the project root directory. + /// Cancellation token. /// true if the configuration is in place; false on failure. - public static async Task ConfigureAsync(DetectedEnvironment env, string projectRoot) + public static async Task ConfigureAsync(DetectedEnvironment env, CancellationToken ct = default) { try { @@ -32,8 +32,8 @@ public static async Task ConfigureAsync(DetectedEnvironment env, string pr JsonObject root; if (File.Exists(configPath)) { - var existingJson = await File.ReadAllTextAsync(configPath).ConfigureAwait(false); - root = JsonNode.Parse(existingJson)?.AsObject() ?? new JsonObject(); + var existingJson = await File.ReadAllTextAsync(configPath, ct).ConfigureAwait(false); + root = JsonNode.Parse(existingJson) as JsonObject ?? new JsonObject(); } else { @@ -65,7 +65,7 @@ public static async Task ConfigureAsync(DetectedEnvironment env, string pr Directory.CreateDirectory(configDir); var options = new JsonSerializerOptions { WriteIndented = true }; - await File.WriteAllTextAsync(configPath, root.ToJsonString(options)).ConfigureAwait(false); + await File.WriteAllTextAsync(configPath, root.ToJsonString(options), ct).ConfigureAwait(false); return true; } diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/Models/MarketplaceManifest.cs b/src/Cli/Microsoft.Maui.Cli/Ai/Models/MarketplaceManifest.cs index a537f47f3..209b09d17 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/Models/MarketplaceManifest.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/Models/MarketplaceManifest.cs @@ -13,12 +13,28 @@ internal sealed class MarketplaceManifest /// public string Name { get; set; } = string.Empty; + /// + /// Owner information for the marketplace. + /// + public MarketplaceOwner? Owner { get; set; } + /// /// List of plugin entries available in the marketplace. /// public PluginEntry[] Plugins { get; set; } = []; } +/// +/// Represents the marketplace owner. +/// +internal sealed class MarketplaceOwner +{ + /// + /// Name of the owning organization or team. + /// + public string Name { get; set; } = string.Empty; +} + /// /// Represents a single plugin entry in the marketplace manifest. /// diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/Models/PluginManifest.cs b/src/Cli/Microsoft.Maui.Cli/Ai/Models/PluginManifest.cs index 17c84ce33..167caff2b 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/Models/PluginManifest.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/Models/PluginManifest.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Text.Json.Serialization; + namespace Microsoft.Maui.Cli.Ai.Models; /// @@ -25,6 +27,20 @@ internal sealed class PluginManifest /// /// Relative paths to skill directories or skill definition globs. + /// Accepts both a single string and an array in plugin.json. /// + [JsonConverter(typeof(StringOrArrayConverter))] public string[] Skills { get; set; } = []; + + /// + /// Optional relative paths to agent definition directories. + /// Accepts both a single string and an array in plugin.json. + /// + [JsonConverter(typeof(StringOrArrayConverter))] + public string[]? Agents { get; set; } + + /// + /// Optional relative path to an LSP server configuration file. + /// + public string? LspServers { get; set; } } diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs b/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs index 6990fe068..5d0a43c93 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs @@ -21,10 +21,12 @@ internal static class SkillInstaller /// Repository in "owner/repo" format. /// Branch name to install from. /// When true, overwrite an existing installation. + /// Cancellation token. /// /// A tuple of (filesInstalled, installPath) where filesInstalled is the number /// of files written and installPath is the absolute path to the skill directory. /// Returns (0, installPath) if the skill is already installed and is false. + /// Returns (0, string.Empty) if the skill name contains invalid characters. /// public static async Task<(int FilesInstalled, string InstallPath)> InstallSkillAsync( HttpClient http, @@ -33,14 +35,18 @@ internal static class SkillInstaller string projectRoot, string repo, string branch, - bool force) + bool force, + CancellationToken ct = default) { + if (skill.Name.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || skill.Name.Contains("..")) + return (0, string.Empty); + var installPath = Path.Combine(env.SkillsDirectory, skill.Name); // Skip if already installed and not forcing. if (!force) { - var existing = await SkillVersionStore.ReadAsync(installPath).ConfigureAwait(false); + var existing = await SkillVersionStore.ReadAsync(installPath, ct).ConfigureAwait(false); if (existing is not null) return (0, installPath); } @@ -48,11 +54,18 @@ internal static class SkillInstaller Directory.CreateDirectory(installPath); var filesInstalled = await MarketplaceClient.DownloadSkillFilesAsync( - http, skill, installPath, repo, branch).ConfigureAwait(false); + http, skill, installPath, repo, branch, ct).ConfigureAwait(false); + + if (filesInstalled == 0) + { + // Clean up empty directory + try { if (Directory.Exists(installPath) && !Directory.EnumerateFileSystemEntries(installPath).Any()) Directory.Delete(installPath); } catch { } + return (0, installPath); + } // Resolve the latest commit SHA for version tracking. var commitSha = await MarketplaceClient.GetRemoteCommitShaAsync( - http, repo, branch, skill.RemotePath).ConfigureAwait(false); + http, repo, branch, skill.RemotePath, ct).ConfigureAwait(false); var version = new InstalledSkillVersion { @@ -64,7 +77,7 @@ internal static class SkillInstaller PluginPath = skill.RemotePath }; - await SkillVersionStore.WriteAsync(installPath, version).ConfigureAwait(false); + await SkillVersionStore.WriteAsync(installPath, version, ct).ConfigureAwait(false); return (filesInstalled, installPath); } diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/SkillVersionStore.cs b/src/Cli/Microsoft.Maui.Cli/Ai/SkillVersionStore.cs index 661cffb14..634a33f87 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/SkillVersionStore.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/SkillVersionStore.cs @@ -19,8 +19,9 @@ internal static class SkillVersionStore /// Reads the .skill-version file from the specified skill directory. /// /// Absolute path to the skill installation directory. + /// Cancellation token. /// The deserialized version info, or null if not found or unreadable. - public static async Task ReadAsync(string skillDir) + public static async Task ReadAsync(string skillDir, CancellationToken ct = default) { var path = Path.Combine(skillDir, VersionFileName); if (!File.Exists(path)) @@ -28,7 +29,7 @@ internal static class SkillVersionStore try { - var json = await File.ReadAllTextAsync(path).ConfigureAwait(false); + var json = await File.ReadAllTextAsync(path, ct).ConfigureAwait(false); return JsonSerializer.Deserialize(json, AiJsonContext.Default.InstalledSkillVersion); } catch (JsonException) @@ -47,7 +48,8 @@ internal static class SkillVersionStore /// /// Absolute path to the skill installation directory. /// Version metadata to persist. - public static async Task WriteAsync(string skillDir, InstalledSkillVersion version) + /// Cancellation token. + public static async Task WriteAsync(string skillDir, InstalledSkillVersion version, CancellationToken ct = default) { Directory.CreateDirectory(skillDir); var path = Path.Combine(skillDir, VersionFileName); @@ -58,6 +60,6 @@ public static async Task WriteAsync(string skillDir, InstalledSkillVersion versi var node = JsonNode.Parse(json); var indented = node?.ToJsonString(new JsonSerializerOptions { WriteIndented = true }) ?? json; - await File.WriteAllTextAsync(path, indented).ConfigureAwait(false); + await File.WriteAllTextAsync(path, indented, ct).ConfigureAwait(false); } } diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/StringOrArrayConverter.cs b/src/Cli/Microsoft.Maui.Cli/Ai/StringOrArrayConverter.cs new file mode 100644 index 000000000..f28a6fab4 --- /dev/null +++ b/src/Cli/Microsoft.Maui.Cli/Ai/StringOrArrayConverter.cs @@ -0,0 +1,48 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Maui.Cli.Ai; + +/// +/// Handles JSON fields that can be either a single string or an array of strings. +/// Per the plugin.json spec, "skills" and "agents" accept both formats. +/// +internal sealed class StringOrArrayConverter : JsonConverter +{ + public override string[] Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + var value = reader.GetString(); + return value is not null ? [value] : []; + } + + if (reader.TokenType == JsonTokenType.StartArray) + { + var list = new List(); + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + if (reader.TokenType == JsonTokenType.String) + { + var item = reader.GetString(); + if (item is not null) + list.Add(item); + } + } + return [.. list]; + } + + return []; + } + + public override void Write(Utf8JsonWriter writer, string[] value, JsonSerializerOptions options) + { + writer.WriteStartArray(); + foreach (var item in value) + writer.WriteStringValue(item); + writer.WriteEndArray(); + } +} diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Add.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Add.cs index e18ee828f..4be80b7d2 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Add.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Add.cs @@ -64,11 +64,11 @@ static Command CreateAddCommand() if (!useJson && formatter is SpectreOutputFormatter spectre) { allSkills = await spectre.StatusAsync("Fetching marketplace...", async () => - await FetchAllSkillsAsync(http, repo, branch)); + await FetchAllSkillsAsync(http, repo, branch, ct)); } else { - allSkills = await FetchAllSkillsAsync(http, repo, branch); + allSkills = await FetchAllSkillsAsync(http, repo, branch, ct); } var skill = allSkills.FirstOrDefault(s => @@ -126,7 +126,7 @@ static Command CreateAddCommand() foreach (var env in environments) { var (filesInstalled, installPath) = await SkillInstaller.InstallSkillAsync( - http, skill, env, workingDir, repo, branch, force); + http, skill, env, workingDir, repo, branch, force, ct); results.Add((env.Kind.ToString(), filesInstalled, installPath)); @@ -141,7 +141,7 @@ static Command CreateAddCommand() { foreach (var env in environments) { - var ok = await McpConfigurator.ConfigureAsync(env, workingDir); + var ok = await McpConfigurator.ConfigureAsync(env, ct); if (ok) formatter.WriteSuccess($"MCP configured for {env.Kind}"); else diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs index bfb0faa78..da4732254 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs @@ -62,11 +62,11 @@ static Command CreateInitCommand() if (!useJson && formatter is SpectreOutputFormatter spectre) { allSkills = await spectre.StatusAsync("Fetching marketplace...", async () => - await FetchAllSkillsAsync(http, repo, branch)); + await FetchAllSkillsAsync(http, repo, branch, ct)); } else { - allSkills = await FetchAllSkillsAsync(http, repo, branch); + allSkills = await FetchAllSkillsAsync(http, repo, branch, ct); } if (allSkills.Count == 0) @@ -96,9 +96,9 @@ static Command CreateInitCommand() return 1; } - if (isCi) + if (isCi || force) { - // In CI mode, create .claude/ by default + // In CI or force mode, create .claude/ by default var claudeDir = Path.Combine(workingDir, ".claude"); Directory.CreateDirectory(claudeDir); environments = AgentEnvironmentDetector.Detect(workingDir); @@ -121,7 +121,8 @@ static Command CreateInitCommand() } } - formatter.WriteInfo($"Detected {environments.Count} environment(s): {string.Join(", ", environments.Select(e => e.Kind))}"); + var envWord = environments.Count == 1 ? "environment" : "environments"; + formatter.WriteInfo($"Detected {environments.Count} {envWord}: {string.Join(", ", environments.Select(e => e.Kind))}"); // Step 3: Select skills List selectedSkills; @@ -188,7 +189,7 @@ static Command CreateInitCommand() // Step 4: Confirmation if (!force && !isCi && !useJson) { - formatter.WriteInfo($"Will install {selectedSkills.Count} skill(s) to {environments.Count} environment(s):"); + formatter.WriteInfo($"Will install {selectedSkills.Count} skill(s) to {environments.Count} {envWord}:"); formatter.WriteTable( selectedSkills, ("Skill", s => s.Name), @@ -223,7 +224,7 @@ from e in environments foreach (var skill in selectedSkills) { var (filesInstalled, installPath) = await SkillInstaller.InstallSkillAsync( - http, skill, env, workingDir, repo, branch, force); + http, skill, env, workingDir, repo, branch, force, ct); results.Add((skill.Name, env.Kind.ToString(), filesInstalled, installPath)); @@ -239,12 +240,15 @@ from e in environments { foreach (var env in environments) { - var ok = await McpConfigurator.ConfigureAsync(env, workingDir); + var ok = await McpConfigurator.ConfigureAsync(env, ct); if (ok) formatter.WriteSuccess($"MCP configured for {env.Kind}"); else formatter.WriteWarning($"Could not configure MCP for {env.Kind}"); } + + if (!useJson) + formatter.WriteInfo("Restart your editor to load the MCP server configuration."); } // Step 7: Summary @@ -270,6 +274,14 @@ from e in environments }; formatter.Write(jsonResult); } + else + { + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine("[dim]Next steps:[/]"); + AnsiConsole.MarkupLine("[dim] Open your AI agent and try asking about .NET MAUI development.[/]"); + AnsiConsole.MarkupLine("[dim] Run [green]maui ai status[/] to check installed skills.[/]"); + AnsiConsole.MarkupLine("[dim] Run [green]maui ai update[/] to sync skills later.[/]"); + } return 0; } @@ -290,20 +302,20 @@ from e in environments /// /// Fetches all skills from every plugin listed in the marketplace manifest. /// - static async Task> FetchAllSkillsAsync(HttpClient http, string repo, string branch) + static async Task> FetchAllSkillsAsync(HttpClient http, string repo, string branch, CancellationToken ct = default) { - var marketplace = await MarketplaceClient.GetMarketplaceAsync(http, repo, branch); + var marketplace = await MarketplaceClient.GetMarketplaceAsync(http, repo, branch, ct); if (marketplace is null) return []; var allSkills = new List(); foreach (var pluginEntry in marketplace.Plugins) { - var plugin = await MarketplaceClient.GetPluginAsync(http, repo, branch, pluginEntry.Source); + var plugin = await MarketplaceClient.GetPluginAsync(http, repo, branch, pluginEntry.Source, ct); if (plugin is null) continue; - var skills = await MarketplaceClient.GetSkillsAsync(http, repo, branch, plugin, pluginEntry.Source); + var skills = await MarketplaceClient.GetSkillsAsync(http, repo, branch, plugin, pluginEntry.Source, ct); allSkills.AddRange(skills); } diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.List.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.List.cs index 1163a74e0..0c159c2a7 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.List.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.List.cs @@ -5,6 +5,7 @@ using System.CommandLine.Parsing; using System.Text.Json.Nodes; using Microsoft.Maui.Cli.Ai; +using Microsoft.Maui.Cli.Ai.Models; using Microsoft.Maui.Cli.Output; namespace Microsoft.Maui.Cli.Commands; @@ -33,15 +34,15 @@ static Command CreateListCommand() { using var http = CreateGitHubHttpClient(); - List allSkills; + List allSkills; if (!useJson && formatter is SpectreOutputFormatter spectre) { allSkills = await spectre.StatusAsync("Fetching marketplace...", async () => - await FetchAllSkillsAsync(http, repo, branch)); + await FetchAllSkillsAsync(http, repo, branch, ct)); } else { - allSkills = await FetchAllSkillsAsync(http, repo, branch); + allSkills = await FetchAllSkillsAsync(http, repo, branch, ct); } if (allSkills.Count == 0) @@ -50,6 +51,23 @@ static Command CreateListCommand() return 1; } + // Check installed status per skill + var workingDir = Directory.GetCurrentDirectory(); + var environments = AgentEnvironmentDetector.Detect(workingDir); + var installedSkills = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var env in environments) + { + if (!Directory.Exists(env.SkillsDirectory)) + continue; + + foreach (var skillDir in Directory.GetDirectories(env.SkillsDirectory)) + { + var version = await SkillVersionStore.ReadAsync(skillDir, ct); + if (version is not null) + installedSkills.Add(Path.GetFileName(skillDir)); + } + } + if (useJson) { var jsonArray = new JsonArray(allSkills.Select(s => (JsonNode)new JsonObject @@ -57,7 +75,8 @@ static Command CreateListCommand() ["skill"] = s.Name, ["plugin"] = s.PluginName, ["description"] = s.Description ?? "", - ["files"] = s.Files.Count + ["files"] = s.Files.Count, + ["installed"] = installedSkills.Contains(s.Name) }).ToArray()); formatter.Write(jsonArray); } @@ -67,7 +86,8 @@ static Command CreateListCommand() allSkills, ("Skill", s => s.Name), ("Plugin", s => s.PluginName), - ("Description", s => s.Description ?? "")); + ("Description", s => s.Description ?? ""), + ("Installed", s => installedSkills.Contains(s.Name) ? "Yes" : "")); } return 0; diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Status.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Status.cs index ed0ada273..f2940bad2 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Status.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Status.cs @@ -3,6 +3,7 @@ using System.CommandLine; using System.CommandLine.Parsing; +using System.Globalization; using System.Text.Json.Nodes; using Microsoft.Maui.Cli.Ai; using Microsoft.Maui.Cli.Ai.Models; @@ -55,7 +56,7 @@ static Command CreateStatusCommand() foreach (var skillDir in Directory.GetDirectories(env.SkillsDirectory)) { var skillName = Path.GetFileName(skillDir); - var version = await SkillVersionStore.ReadAsync(skillDir); + var version = await SkillVersionStore.ReadAsync(skillDir, ct); if (version is null) { @@ -64,7 +65,7 @@ static Command CreateStatusCommand() } var installed = version.UpdatedAt is not null - ? DateTime.Parse(version.UpdatedAt).ToLocalTime().ToString("yyyy-MM-dd HH:mm") + ? (DateTime.TryParse(version.UpdatedAt, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dt) ? dt.ToLocalTime().ToString("yyyy-MM-dd HH:mm") : version.UpdatedAt) : "Unknown"; var status = "Installed"; @@ -72,7 +73,7 @@ static Command CreateStatusCommand() if (checkUpdates && http is not null && version.PluginPath is not null) { var remoteSha = await MarketplaceClient.GetRemoteCommitShaAsync( - http, repo, branch, version.PluginPath); + http, repo, branch, version.PluginPath, ct); if (remoteSha is not null && version.Commit is not null) { diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs index 3c0172cc1..c3d011cf3 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs @@ -72,7 +72,7 @@ static Command CreateUpdateCommand() !skillFilter.Any(f => string.Equals(f, skillName, StringComparison.OrdinalIgnoreCase))) continue; - var version = await SkillVersionStore.ReadAsync(skillDir); + var version = await SkillVersionStore.ReadAsync(skillDir, ct); if (version is null) continue; @@ -80,7 +80,7 @@ static Command CreateUpdateCommand() if (version.PluginPath is not null) { var remoteSha = await MarketplaceClient.GetRemoteCommitShaAsync( - http, repo, branch, version.PluginPath); + http, repo, branch, version.PluginPath, ct); var needsUpdate = force || remoteSha is null || @@ -134,11 +134,11 @@ version.Commit is null || if (!useJson && formatter is SpectreOutputFormatter spectre) { allSkills = await spectre.StatusAsync("Fetching marketplace...", async () => - await FetchAllSkillsAsync(http, repo, branch)); + await FetchAllSkillsAsync(http, repo, branch, ct)); } else { - allSkills = await FetchAllSkillsAsync(http, repo, branch); + allSkills = await FetchAllSkillsAsync(http, repo, branch, ct); } var results = new List<(string Skill, string Env, int Files)>(); @@ -155,7 +155,7 @@ version.Commit is null || } var (filesInstalled, _) = await SkillInstaller.InstallSkillAsync( - http, skillInfo, env, workingDir, repo, branch, force: true); + http, skillInfo, env, workingDir, repo, branch, force: true, ct); results.Add((skillName, env.Kind.ToString(), filesInstalled)); formatter.WriteSuccess($"Updated {skillName} → {env.Kind} ({filesInstalled} files)"); diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.cs index d4a417ae9..85fa0e22c 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.cs @@ -12,7 +12,6 @@ public static partial class AiCommands { private const string DefaultRepo = "dotnet/maui-labs"; private const string DefaultBranch = "main"; - private const string DefaultMarketplacePath = ".github/plugin/marketplace.json"; public static Command Create() { @@ -29,13 +28,13 @@ public static Command Create() /// Creates the shared --repo option used by multiple subcommands. /// static Option CreateRepoOption() => - new("--repo") { Description = "GitHub repository", DefaultValueFactory = _ => DefaultRepo }; + new("--repo") { Description = "GitHub repository", DefaultValueFactory = _ => DefaultRepo, Hidden = true }; /// /// Creates the shared --branch / -b option used by multiple subcommands. /// static Option CreateBranchOption() => - new("--branch", "-b") { Description = "GitHub branch", DefaultValueFactory = _ => DefaultBranch }; + new("--branch", "-b") { Description = "GitHub branch", DefaultValueFactory = _ => DefaultBranch, Hidden = true }; /// /// Creates the shared --force / -y option for skipping confirmation prompts. From 070fd24703f745ece813a23c4b448565666a8065 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Fri, 17 Apr 2026 09:39:22 +0200 Subject: [PATCH 03/31] fix: Address round 2 cancellation, converter, error reportingreview - FetchStringAsync/FetchBytesAsync: only swallow real HTTP timeouts, let user cancellation (Ctrl+C) propagate correctly - StringOrArrayConverter: add reader.Skip() for non-string array elements (nested objects/arrays/nulls) to prevent reader corruption - SkillInstaller: return -1 for invalid skill names; callers now distinguish failure from 'already installed' skip proper singular/plural - McpConfigurator: catch UnauthorizedAccessException - Add StringOrArrayConverterTests (9 tests) and path traversal tests (3) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MarketplaceClientTests.cs | 105 +++++++++++++++- .../StringOrArrayConverterTests.cs | 113 ++++++++++++++++++ .../Ai/MarketplaceClient.cs | 8 +- .../Microsoft.Maui.Cli/Ai/McpConfigurator.cs | 4 + .../Microsoft.Maui.Cli/Ai/SkillInstaller.cs | 4 +- .../Ai/StringOrArrayConverter.cs | 4 + .../Commands/AiCommands.Add.cs | 8 +- .../Commands/AiCommands.Init.cs | 9 +- .../Commands/AiCommands.Update.cs | 11 +- 9 files changed, 253 insertions(+), 13 deletions(-) create mode 100644 src/Cli/Microsoft.Maui.Cli.UnitTests/StringOrArrayConverterTests.cs diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs index 0b6402d2b..4ac07a101 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs @@ -1,13 +1,28 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Net; using Microsoft.Maui.Cli.Ai; +using Microsoft.Maui.Cli.Ai.Models; using Xunit; namespace Microsoft.Maui.Cli.UnitTests; -public class MarketplaceClientTests +public class MarketplaceClientTests : IDisposable { + private readonly string _tempDir; + + public MarketplaceClientTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, recursive: true); + } [Fact] public void ParseFrontmatter_ValidFrontmatter_ExtractsNameAndDescription() { @@ -205,4 +220,92 @@ public void ParseFrontmatter_ContentAfterFrontmatter_IsIgnored() Assert.Equal("frontmatter-skill", name); Assert.Equal("Only frontmatter matters", description); } + + [Fact] + public async Task DownloadSkillFilesAsync_RejectsPathTraversal() + { + var handler = new FakeHttpMessageHandler(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent("malicious"u8.ToArray()) + }); + using var http = new HttpClient(handler); + + var skill = new SkillInfo + { + Name = "evil-skill", + RemotePath = "plugins/evil", + Files = ["plugins/evil/../../../etc/passwd"] + }; + + var count = await MarketplaceClient.DownloadSkillFilesAsync( + http, skill, _tempDir, "owner/repo", "main"); + + Assert.Equal(0, count); + // Verify no file was written outside the dest directory + Assert.Empty(Directory.GetFiles(_tempDir, "*", SearchOption.AllDirectories)); + } + + [Fact] + public async Task DownloadSkillFilesAsync_RejectsPathTraversal_InRelativePath() + { + var handler = new FakeHttpMessageHandler(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent("malicious"u8.ToArray()) + }); + using var http = new HttpClient(handler); + + var skill = new SkillInfo + { + Name = "evil-skill", + RemotePath = "plugins/evil", + Files = ["plugins/evil/../../breakout.txt"] + }; + + var count = await MarketplaceClient.DownloadSkillFilesAsync( + http, skill, _tempDir, "owner/repo", "main"); + + Assert.Equal(0, count); + Assert.Empty(Directory.GetFiles(_tempDir, "*", SearchOption.AllDirectories)); + } + + [Fact] + public async Task DownloadSkillFilesAsync_AllowsSafeRelativePath() + { + var handler = new FakeHttpMessageHandler(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent("safe content"u8.ToArray()) + }); + using var http = new HttpClient(handler); + + var skill = new SkillInfo + { + Name = "good-skill", + RemotePath = "plugins/good", + Files = ["plugins/good/SKILL.md"] + }; + + var count = await MarketplaceClient.DownloadSkillFilesAsync( + http, skill, _tempDir, "owner/repo", "main"); + + Assert.Equal(1, count); + Assert.True(File.Exists(Path.Combine(_tempDir, "SKILL.md"))); + } + + /// + /// Minimal HttpMessageHandler that returns a fixed response for every request. + /// + private sealed class FakeHttpMessageHandler : HttpMessageHandler + { + private readonly HttpResponseMessage _response; + + public FakeHttpMessageHandler(HttpResponseMessage response) => _response = response; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(_response); + + protected override void Dispose(bool disposing) + { + // Don't dispose _response — the caller owns it via the test scope + } + } } diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/StringOrArrayConverterTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/StringOrArrayConverterTests.cs new file mode 100644 index 000000000..4cbfad739 --- /dev/null +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/StringOrArrayConverterTests.cs @@ -0,0 +1,113 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Microsoft.Maui.Cli.Ai; +using Xunit; + +namespace Microsoft.Maui.Cli.UnitTests; + +public class StringOrArrayConverterTests +{ + private static readonly JsonSerializerOptions Options = new() + { + Converters = { new StringOrArrayConverter() } + }; + + [Fact] + public void Read_SingleString_ReturnsArrayWithOneElement() + { + var json = "\"./skills/\""; + var result = JsonSerializer.Deserialize(json, Options); + + Assert.NotNull(result); + Assert.Single(result); + Assert.Equal("./skills/", result[0]); + } + + [Fact] + public void Read_Array_ReturnsAllElements() + { + var json = "[\"./skills/\"]"; + var result = JsonSerializer.Deserialize(json, Options); + + Assert.NotNull(result); + Assert.Single(result); + Assert.Equal("./skills/", result[0]); + } + + [Fact] + public void Read_ArrayMultipleElements_ReturnsAll() + { + var json = "[\"a\", \"b\", \"c\"]"; + var result = JsonSerializer.Deserialize(json, Options); + + Assert.NotNull(result); + Assert.Equal(3, result.Length); + Assert.Equal(["a", "b", "c"], result); + } + + [Fact] + public void Read_ArrayWithNull_SkipsNull() + { + var json = "[\"a\", null, \"b\"]"; + var result = JsonSerializer.Deserialize(json, Options); + + Assert.NotNull(result); + Assert.Equal(2, result.Length); + Assert.Equal(["a", "b"], result); + } + + [Fact] + public void Read_ArrayWithNestedObject_SkipsObject() + { + var json = "[\"a\", {\"key\": \"value\"}, \"b\"]"; + var result = JsonSerializer.Deserialize(json, Options); + + Assert.NotNull(result); + Assert.Equal(2, result.Length); + Assert.Equal(["a", "b"], result); + } + + [Fact] + public void Read_ArrayWithNestedArray_SkipsNestedArray() + { + var json = "[\"a\", [\"nested\"], \"b\"]"; + var result = JsonSerializer.Deserialize(json, Options); + + Assert.NotNull(result); + Assert.Equal(2, result.Length); + Assert.Equal(["a", "b"], result); + } + + [Fact] + public void Read_ArrayWithNumber_SkipsNumber() + { + var json = "[\"a\", 42, \"b\"]"; + var result = JsonSerializer.Deserialize(json, Options); + + Assert.NotNull(result); + Assert.Equal(2, result.Length); + Assert.Equal(["a", "b"], result); + } + + [Fact] + public void Read_EmptyArray_ReturnsEmpty() + { + var json = "[]"; + var result = JsonSerializer.Deserialize(json, Options); + + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public void Write_RoundTrips() + { + var original = new[] { "x", "y" }; + var json = JsonSerializer.Serialize(original, Options); + var result = JsonSerializer.Deserialize(json, Options); + + Assert.Equal(original, result); + } +} diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs b/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs index 486b46774..3b0d26bb5 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs @@ -310,9 +310,9 @@ private static string NormalizePath(string path) { return null; } - catch (TaskCanceledException) + catch (TaskCanceledException) when (!ct.IsCancellationRequested) { - return null; + return null; // real HTTP timeout } } @@ -330,9 +330,9 @@ private static string NormalizePath(string path) { return null; } - catch (TaskCanceledException) + catch (TaskCanceledException) when (!ct.IsCancellationRequested) { - return null; + return null; // real HTTP timeout } } } diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs b/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs index 6560901b5..2be12bc75 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs @@ -73,6 +73,10 @@ public static async Task ConfigureAsync(DetectedEnvironment env, Cancellat { return false; } + catch (UnauthorizedAccessException) + { + return false; + } catch (JsonException) { return false; diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs b/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs index 5d0a43c93..ef8458633 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs @@ -26,7 +26,7 @@ internal static class SkillInstaller /// A tuple of (filesInstalled, installPath) where filesInstalled is the number /// of files written and installPath is the absolute path to the skill directory. /// Returns (0, installPath) if the skill is already installed and is false. - /// Returns (0, string.Empty) if the skill name contains invalid characters. + /// Returns (-1, string.Empty) if the skill name contains invalid characters. /// public static async Task<(int FilesInstalled, string InstallPath)> InstallSkillAsync( HttpClient http, @@ -39,7 +39,7 @@ internal static class SkillInstaller CancellationToken ct = default) { if (skill.Name.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || skill.Name.Contains("..")) - return (0, string.Empty); + return (-1, string.Empty); var installPath = Path.Combine(env.SkillsDirectory, skill.Name); diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/StringOrArrayConverter.cs b/src/Cli/Microsoft.Maui.Cli/Ai/StringOrArrayConverter.cs index f28a6fab4..67abba1d3 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/StringOrArrayConverter.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/StringOrArrayConverter.cs @@ -31,6 +31,10 @@ public override string[] Read(ref Utf8JsonReader reader, Type typeToConvert, Jso if (item is not null) list.Add(item); } + else + { + reader.Skip(); + } } return [.. list]; } diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Add.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Add.cs index 4be80b7d2..60901812a 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Add.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Add.cs @@ -112,7 +112,7 @@ static Command CreateAddCommand() // Confirm unless --force, --ci, or --json if (!force && !isCi && !useJson) { - formatter.WriteInfo($"Will install '{skill.Name}' ({skill.Files.Count} files) to {environments.Count} environment(s)."); + formatter.WriteInfo($"Will install '{skill.Name}' ({skill.Files.Count} files) to {environments.Count} {(environments.Count == 1 ? "environment" : "environments")}."); if (!AnsiConsole.Confirm("Proceed?", defaultValue: true)) { formatter.WriteInfo("Installation cancelled."); @@ -130,7 +130,11 @@ static Command CreateAddCommand() results.Add((env.Kind.ToString(), filesInstalled, installPath)); - if (filesInstalled > 0) + if (filesInstalled < 0) + { + formatter.WriteWarning($"Skill '{skill.Name}' has an invalid name and cannot be installed."); + } + else if (filesInstalled > 0) formatter.WriteSuccess($"Installed {skill.Name} → {env.Kind} ({filesInstalled} files)"); else formatter.WriteInfo($"Skipped {skill.Name} → {env.Kind} (already installed, use --force to overwrite)"); diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs index da4732254..726fd0b60 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs @@ -189,7 +189,8 @@ static Command CreateInitCommand() // Step 4: Confirmation if (!force && !isCi && !useJson) { - formatter.WriteInfo($"Will install {selectedSkills.Count} skill(s) to {environments.Count} {envWord}:"); + var skillWord = selectedSkills.Count == 1 ? "skill" : "skills"; + formatter.WriteInfo($"Will install {selectedSkills.Count} {skillWord} to {environments.Count} {envWord}:"); formatter.WriteTable( selectedSkills, ("Skill", s => s.Name), @@ -228,7 +229,11 @@ from e in environments results.Add((skill.Name, env.Kind.ToString(), filesInstalled, installPath)); - if (filesInstalled > 0) + if (filesInstalled < 0) + { + formatter.WriteWarning($"Skill '{skill.Name}' has an invalid name and cannot be installed."); + } + else if (filesInstalled > 0) formatter.WriteSuccess($"Installed {skill.Name} → {env.Kind} ({filesInstalled} files)"); else formatter.WriteInfo($"Skipped {skill.Name} → {env.Kind} (already installed)"); diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs index c3d011cf3..1b8cc3eb5 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs @@ -99,7 +99,8 @@ version.Commit is null || return 0; } - formatter.WriteInfo($"Found {updatable.Count} skill(s) with updates available."); + var updateWord = updatable.Count == 1 ? "skill" : "skills"; + formatter.WriteInfo($"Found {updatable.Count} {updateWord} with updates available."); if (dryRun) { @@ -158,7 +159,13 @@ version.Commit is null || http, skillInfo, env, workingDir, repo, branch, force: true, ct); results.Add((skillName, env.Kind.ToString(), filesInstalled)); - formatter.WriteSuccess($"Updated {skillName} → {env.Kind} ({filesInstalled} files)"); + + if (filesInstalled < 0) + formatter.WriteWarning($"Skill '{skillName}' has an invalid name and cannot be updated."); + else if (filesInstalled > 0) + formatter.WriteSuccess($"Updated {skillName} → {env.Kind} ({filesInstalled} files)"); + else + formatter.WriteInfo($"Skipped {skillName} → {env.Kind} (no files downloaded)"); } if (useJson) From e26d41594ba1f0b60fe74d79a91dc7b35b20bb14 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Fri, 17 Apr 2026 09:58:34 +0200 Subject: [PATCH 04/31] fix: Address PR review tree caching, dedup, error handlingfeedback - Cache GitHub tree fetch across plugins (single API call per branch) - Use projectRoot to anchor relative skill directories - De-duplicate updates when VS Code and Copilot CLI share .github/skills - Don't trigger reinstall when remote SHA is unavailable (null != outdated) - FetchStringAsync/FetchBytesAsync: return null only for 404, propagate other HTTP errors for meaningful caller messages Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Ai/MarketplaceClient.cs | 63 ++++++++++++++----- .../Microsoft.Maui.Cli/Ai/SkillInstaller.cs | 7 ++- .../Commands/AiCommands.Init.cs | 6 +- .../Commands/AiCommands.Update.cs | 17 +++-- 4 files changed, 71 insertions(+), 22 deletions(-) diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs b/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs index 3b0d26bb5..6c0410fbf 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs @@ -53,36 +53,32 @@ internal static class MarketplaceClient } /// - /// Discovers all skills within a plugin by enumerating the repository tree - /// and parsing SKILL.md frontmatter. + /// Fetches the full recursive tree for the given branch and returns the entries + /// as (path, type) pairs. Results can be cached and passed to + /// to avoid redundant API calls. /// /// Configured . /// Repository in "owner/repo" format. /// Branch name to read from. - /// The plugin manifest whose skills to discover. - /// Repository-relative path to the plugin directory. - /// List of discovered skills (empty on failure). - public static async Task> GetSkillsAsync( - HttpClient http, string repo, string branch, PluginManifest plugin, string pluginSourcePath, CancellationToken ct = default) + /// Cancellation token. + /// List of tree entries, or null on failure. + public static async Task?> FetchTreeEntriesAsync( + HttpClient http, string repo, string branch, CancellationToken ct = default) { - var skills = new List(); - - // Resolve the branch to a tree SHA, then fetch the full recursive tree. var treeSha = await ResolveTreeShaAsync(http, repo, branch, ct).ConfigureAwait(false); if (treeSha is null) - return skills; + return null; var treeUrl = $"{GitHubApiBase}/repos/{repo}/git/trees/{treeSha}?recursive=1"; var treeJson = await FetchStringAsync(http, treeUrl, ct).ConfigureAwait(false); if (treeJson is null) - return skills; + return null; var treeNode = JsonNode.Parse(treeJson); var treeArray = treeNode?["tree"]?.AsArray(); if (treeArray is null) - return skills; + return null; - // Collect all tree entries as (path, type) pairs. var entries = new List<(string Path, string Type)>(); foreach (var entry in treeArray) { @@ -92,6 +88,33 @@ public static async Task> GetSkillsAsync( entries.Add((entryPath, entryType)); } + return entries; + } + + /// + /// Discovers all skills within a plugin by enumerating the repository tree + /// and parsing SKILL.md frontmatter. + /// + /// Configured . + /// Repository in "owner/repo" format. + /// Branch name to read from. + /// The plugin manifest whose skills to discover. + /// Repository-relative path to the plugin directory. + /// + /// Optional pre-fetched tree entries from . + /// When null, the tree is fetched automatically (one API call per invocation). + /// + /// List of discovered skills (empty on failure). + public static async Task> GetSkillsAsync( + HttpClient http, string repo, string branch, PluginManifest plugin, string pluginSourcePath, + List<(string Path, string Type)>? cachedTreeEntries = null, CancellationToken ct = default) + { + var skills = new List(); + + var entries = cachedTreeEntries ?? await FetchTreeEntriesAsync(http, repo, branch, ct).ConfigureAwait(false); + if (entries is null) + return skills; + var normalizedPluginPath = NormalizePath(pluginSourcePath); foreach (var skillGlob in plugin.Skills) @@ -301,9 +324,14 @@ private static string NormalizePath(string path) try { using var response = await http.GetAsync(url, ct).ConfigureAwait(false); - if (!response.IsSuccessStatusCode) + + // Return null only for 404 (resource not found); propagate other errors + // so callers can surface meaningful messages. + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) return null; + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); } catch (HttpRequestException) @@ -321,9 +349,12 @@ private static string NormalizePath(string path) try { using var response = await http.GetAsync(url, ct).ConfigureAwait(false); - if (!response.IsSuccessStatusCode) + + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) return null; + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false); } catch (HttpRequestException) diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs b/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs index ef8458633..7d5a59e2c 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs @@ -41,7 +41,12 @@ internal static class SkillInstaller if (skill.Name.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || skill.Name.Contains("..")) return (-1, string.Empty); - var installPath = Path.Combine(env.SkillsDirectory, skill.Name); + // If the skills directory is not rooted, resolve it relative to the project root. + var skillsDir = Path.IsPathRooted(env.SkillsDirectory) + ? env.SkillsDirectory + : Path.GetFullPath(Path.Combine(projectRoot, env.SkillsDirectory)); + + var installPath = Path.Combine(skillsDir, skill.Name); // Skip if already installed and not forcing. if (!force) diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs index 726fd0b60..50eb6b2f2 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs @@ -314,13 +314,17 @@ static async Task> FetchAllSkillsAsync(HttpClient http, string r return []; var allSkills = new List(); + + // Fetch the repository tree once and share it across all plugins. + var treeEntries = await MarketplaceClient.FetchTreeEntriesAsync(http, repo, branch, ct); + foreach (var pluginEntry in marketplace.Plugins) { var plugin = await MarketplaceClient.GetPluginAsync(http, repo, branch, pluginEntry.Source, ct); if (plugin is null) continue; - var skills = await MarketplaceClient.GetSkillsAsync(http, repo, branch, plugin, pluginEntry.Source, ct); + var skills = await MarketplaceClient.GetSkillsAsync(http, repo, branch, plugin, pluginEntry.Source, treeEntries, ct); allSkills.AddRange(skills); } diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs index 1b8cc3eb5..54545b746 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs @@ -56,8 +56,10 @@ static Command CreateUpdateCommand() using var http = CreateGitHubHttpClient(); - // Scan installed skills and check for updates + // Scan installed skills and check for updates; de-duplicate by resolved path + // so environments sharing the same skills directory are not updated twice. var updatable = new List<(DetectedEnvironment Env, string SkillDir, string SkillName, InstalledSkillVersion Version)>(); + var processedPaths = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var env in environments) { @@ -66,6 +68,10 @@ static Command CreateUpdateCommand() foreach (var skillDir in Directory.GetDirectories(env.SkillsDirectory)) { + var resolvedPath = Path.GetFullPath(skillDir); + if (!processedPaths.Add(resolvedPath)) + continue; + var skillName = Path.GetFileName(skillDir); if (skillFilter is { Length: > 0 } && @@ -82,10 +88,13 @@ static Command CreateUpdateCommand() var remoteSha = await MarketplaceClient.GetRemoteCommitShaAsync( http, repo, branch, version.PluginPath, ct); + // Only update when the remote SHA is known AND differs from local, + // unless --force. When remoteSha is null (e.g. GitHub unreachable) + // we skip the update to avoid unnecessary reinstalls. var needsUpdate = force || - remoteSha is null || - version.Commit is null || - !string.Equals(remoteSha, version.Commit, StringComparison.OrdinalIgnoreCase); + (remoteSha is not null && + (version.Commit is null || + !string.Equals(remoteSha, version.Commit, StringComparison.OrdinalIgnoreCase))); if (needsUpdate) updatable.Add((env, skillDir, skillName, version)); From c5d0549a4abdb8c02969fb6e62d6d7d43f00f99a Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Fri, 17 Apr 2026 10:38:43 +0200 Subject: [PATCH 05/31] fix: Final indentation, return codes, test coveragepolish - Fix tab indentation in Init.cs and Update.cs - SkillInstaller returns -2 for download failures (distinct from -1 invalid name, 0 already installed) - Callers show 'check your network connection' for -2 - Add SkillInstallerTests (3 tests: invalid names, valid name) - Add McpConfigurator corrupted JSON test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../McpConfiguratorTests.cs | 22 +++++ .../SkillInstallerTests.cs | 97 +++++++++++++++++++ .../Microsoft.Maui.Cli/Ai/SkillInstaller.cs | 3 +- .../Commands/AiCommands.Add.cs | 6 +- .../Commands/AiCommands.Init.cs | 8 +- .../Commands/AiCommands.Update.cs | 6 +- 6 files changed, 136 insertions(+), 6 deletions(-) create mode 100644 src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs index 1cf933e24..578379d9f 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs @@ -220,6 +220,28 @@ public async Task ConfigureAsync_CreatesConfigDirectory_WhenMissing() Assert.True(File.Exists(configPath)); } + [Fact] + public async Task ConfigureAsync_CorruptedJson_ReturnsFalse() + { + var configDir = Path.Combine(_tempDir, ".claude"); + Directory.CreateDirectory(configDir); + var configPath = Path.Combine(configDir, "mcp.json"); + + // Write invalid JSON content + await File.WriteAllTextAsync(configPath, "not json at all {{{"); + + var env = new DetectedEnvironment + { + Kind = AgentEnvironmentKind.Claude, + McpConfigPath = configPath, + SkillsDirectory = Path.Combine(_tempDir, ".claude", "skills") + }; + + var result = await McpConfigurator.ConfigureAsync(env); + + Assert.False(result); + } + [Fact] public async Task ConfigureAsync_ReturnsTrue_WhenEntryAlreadyExists() { diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs new file mode 100644 index 000000000..b6dc8380e --- /dev/null +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs @@ -0,0 +1,97 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Maui.Cli.Ai; +using Microsoft.Maui.Cli.Ai.Models; +using Xunit; + +namespace Microsoft.Maui.Cli.UnitTests; + +public class SkillInstallerTests : IDisposable +{ + private readonly string _tempDir; + + public SkillInstallerTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, recursive: true); + } + + [Fact] + public async Task InstallSkillAsync_InvalidName_PathTraversal_ReturnsNegativeOne() + { + var skill = new SkillInfo { Name = "../escape" }; + var env = new DetectedEnvironment + { + Kind = AgentEnvironmentKind.Claude, + SkillsDirectory = Path.Combine(_tempDir, "skills") + }; + + var (filesInstalled, installPath) = await SkillInstaller.InstallSkillAsync( + new HttpClient(), skill, env, _tempDir, "owner/repo", "main", force: false); + + Assert.Equal(-1, filesInstalled); + Assert.Equal(string.Empty, installPath); + } + + [Fact] + public async Task InstallSkillAsync_InvalidName_PathSeparator_ReturnsNegativeOne() + { + var separator = Path.DirectorySeparatorChar.ToString(); + var skill = new SkillInfo { Name = $"bad{separator}name" }; + var env = new DetectedEnvironment + { + Kind = AgentEnvironmentKind.Claude, + SkillsDirectory = Path.Combine(_tempDir, "skills") + }; + + var (filesInstalled, installPath) = await SkillInstaller.InstallSkillAsync( + new HttpClient(), skill, env, _tempDir, "owner/repo", "main", force: false); + + Assert.Equal(-1, filesInstalled); + Assert.Equal(string.Empty, installPath); + } + + [Fact] + public async Task InstallSkillAsync_ValidName_DoesNotReturnNegativeOne() + { + var skill = new SkillInfo + { + Name = "valid-skill", + RemotePath = ".github/plugins/maui/skills/valid-skill", + Files = ["file1.md"] + }; + var env = new DetectedEnvironment + { + Kind = AgentEnvironmentKind.Claude, + SkillsDirectory = Path.Combine(_tempDir, "skills") + }; + + // This will fail at the HTTP level (no real server), but it should NOT + // return -1 because the name validation passes. We expect either an + // exception from the HTTP call or a -2 (download returned 0 files). + try + { + var (filesInstalled, _) = await SkillInstaller.InstallSkillAsync( + new HttpClient(), skill, env, _tempDir, "owner/repo", "main", force: false); + + // If it didn't throw, it should not be -1 (invalid name) + Assert.NotEqual(-1, filesInstalled); + } + catch (HttpRequestException) + { + // Expected: the HttpClient has no BaseAddress so the HTTP call fails. + // The important thing is we got past the name validation. + } + catch (InvalidOperationException) + { + // Also acceptable: HttpClient may throw this without a BaseAddress. + } + } +} diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs b/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs index 7d5a59e2c..48c3f0fcb 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs @@ -27,6 +27,7 @@ internal static class SkillInstaller /// of files written and installPath is the absolute path to the skill directory. /// Returns (0, installPath) if the skill is already installed and is false. /// Returns (-1, string.Empty) if the skill name contains invalid characters. + /// Returns (-2, installPath) if the download produced zero files (network or remote failure). /// public static async Task<(int FilesInstalled, string InstallPath)> InstallSkillAsync( HttpClient http, @@ -65,7 +66,7 @@ internal static class SkillInstaller { // Clean up empty directory try { if (Directory.Exists(installPath) && !Directory.EnumerateFileSystemEntries(installPath).Any()) Directory.Delete(installPath); } catch { } - return (0, installPath); + return (-2, installPath); } // Resolve the latest commit SHA for version tracking. diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Add.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Add.cs index 60901812a..cab3f78f9 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Add.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Add.cs @@ -130,10 +130,14 @@ static Command CreateAddCommand() results.Add((env.Kind.ToString(), filesInstalled, installPath)); - if (filesInstalled < 0) + if (filesInstalled == -1) { formatter.WriteWarning($"Skill '{skill.Name}' has an invalid name and cannot be installed."); } + else if (filesInstalled == -2) + { + formatter.WriteWarning($"Failed to download skill files for '{skill.Name}'. Check your network connection."); + } else if (filesInstalled > 0) formatter.WriteSuccess($"Installed {skill.Name} → {env.Kind} ({filesInstalled} files)"); else diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs index 50eb6b2f2..9239449d6 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs @@ -189,7 +189,7 @@ static Command CreateInitCommand() // Step 4: Confirmation if (!force && !isCi && !useJson) { - var skillWord = selectedSkills.Count == 1 ? "skill" : "skills"; + var skillWord = selectedSkills.Count == 1 ? "skill" : "skills"; formatter.WriteInfo($"Will install {selectedSkills.Count} {skillWord} to {environments.Count} {envWord}:"); formatter.WriteTable( selectedSkills, @@ -229,10 +229,14 @@ from e in environments results.Add((skill.Name, env.Kind.ToString(), filesInstalled, installPath)); - if (filesInstalled < 0) + if (filesInstalled == -1) { formatter.WriteWarning($"Skill '{skill.Name}' has an invalid name and cannot be installed."); } + else if (filesInstalled == -2) + { + formatter.WriteWarning($"Failed to download skill files for '{skill.Name}'. Check your network connection."); + } else if (filesInstalled > 0) formatter.WriteSuccess($"Installed {skill.Name} → {env.Kind} ({filesInstalled} files)"); else diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs index 54545b746..53e63a2a1 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs @@ -108,7 +108,7 @@ static Command CreateUpdateCommand() return 0; } - var updateWord = updatable.Count == 1 ? "skill" : "skills"; + var updateWord = updatable.Count == 1 ? "skill" : "skills"; formatter.WriteInfo($"Found {updatable.Count} {updateWord} with updates available."); if (dryRun) @@ -169,8 +169,10 @@ static Command CreateUpdateCommand() results.Add((skillName, env.Kind.ToString(), filesInstalled)); - if (filesInstalled < 0) + if (filesInstalled == -1) formatter.WriteWarning($"Skill '{skillName}' has an invalid name and cannot be updated."); + else if (filesInstalled == -2) + formatter.WriteWarning($"Failed to download skill files for '{skillName}'. Check your network connection."); else if (filesInstalled > 0) formatter.WriteSuccess($"Updated {skillName} → {env.Kind} ({filesInstalled} files)"); else From 63cd0802712c9e9e80c7be08667c88d059d088bc Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Fri, 17 Apr 2026 10:43:45 +0200 Subject: [PATCH 06/31] fix: Dry-run before confirmation, remove dead code, fix comment - Move dry-run check before confirmation prompt in Init.cs - Remove no-op AddChoices(Array.Empty) call - Fix misleading comment in FetchStringAsync Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Ai/MarketplaceClient.cs | 4 +-- .../Commands/AiCommands.Init.cs | 32 +++++++++---------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs b/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs index 6c0410fbf..7760cdb62 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs @@ -325,8 +325,8 @@ private static string NormalizePath(string path) { using var response = await http.GetAsync(url, ct).ConfigureAwait(false); - // Return null only for 404 (resource not found); propagate other errors - // so callers can surface meaningful messages. + // Return null for 404 (resource not found) and network errors; + // user cancellation (Ctrl+C) propagates via OperationCanceledException. if (response.StatusCode == System.Net.HttpStatusCode.NotFound) return null; diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs index 9239449d6..56f047bcb 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs @@ -159,7 +159,6 @@ static Command CreateInitCommand() } // Pre-select all by default - prompt.AddChoices(Array.Empty()); foreach (var skill in allSkills) { var label = string.IsNullOrEmpty(skill.Description) @@ -186,7 +185,21 @@ static Command CreateInitCommand() } } - // Step 4: Confirmation + // Step 4: Dry run check (before confirmation prompt) + if (dryRun) + { + formatter.WriteInfo("[Dry run] Would install the following skills:"); + formatter.WriteTable( + from s in selectedSkills + from e in environments + select new { s.Name, Env = e.Kind.ToString(), Path = Path.Combine(e.SkillsDirectory, s.Name) }, + ("Skill", x => x.Name), + ("Environment", x => x.Env), + ("Path", x => x.Path)); + return 0; + } + + // Step 5: Confirmation if (!force && !isCi && !useJson) { var skillWord = selectedSkills.Count == 1 ? "skill" : "skills"; @@ -204,20 +217,7 @@ static Command CreateInitCommand() } } - if (dryRun) - { - formatter.WriteInfo("[Dry run] Would install the following skills:"); - formatter.WriteTable( - from s in selectedSkills - from e in environments - select new { s.Name, Env = e.Kind.ToString(), Path = Path.Combine(e.SkillsDirectory, s.Name) }, - ("Skill", x => x.Name), - ("Environment", x => x.Env), - ("Path", x => x.Path)); - return 0; - } - - // Step 5: Install skills + // Step 6: Install skills var results = new List<(string Skill, string Env, int Files, string Path)>(); foreach (var env in environments) From 5cdd66bec1f22c88a0df05e646b88096170d631c Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Fri, 17 Apr 2026 10:57:58 +0200 Subject: [PATCH 07/31] fix: Path normalization and YAML block scalar parsing - NormalizePath: collapse middle './' segments (e.g. 'plugins/dotnet-maui/./skills') which prevented skill discovery when plugin.json uses './skills/' paths - ParseFrontmatter: handle YAML block scalar indicators (>-, >, |, etc.) by reading indented continuation lines for multi-line descriptions Both bugs found during local end-to-end testing against the live marketplace. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Ai/MarketplaceClient.cs | 36 +++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs b/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs index 7760cdb62..a1e688018 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs @@ -277,13 +277,41 @@ internal static (string? Name, string? Description) ParseFrontmatter(string cont return (name, description); var frontmatter = trimmed[3..endIndex]; - foreach (var line in frontmatter.Split('\n')) + var lines = frontmatter.Split('\n'); + for (var i = 0; i < lines.Length; i++) { - var trimmedLine = line.Trim(); + var trimmedLine = lines[i].Trim(); if (trimmedLine.StartsWith("name:", StringComparison.OrdinalIgnoreCase)) name = StripYamlValue(trimmedLine["name:".Length..]); else if (trimmedLine.StartsWith("description:", StringComparison.OrdinalIgnoreCase)) - description = StripYamlValue(trimmedLine["description:".Length..]); + { + var rawValue = StripYamlValue(trimmedLine["description:".Length..]); + if (rawValue is ">-" or ">" or "|" or "|-" or "|+" or ">+") + { + // YAML block scalar — read indented continuation lines + var sb = new System.Text.StringBuilder(); + while (i + 1 < lines.Length) + { + var nextLine = lines[i + 1]; + if (nextLine.Length > 0 && (nextLine[0] == ' ' || nextLine[0] == '\t')) + { + if (sb.Length > 0) + sb.Append(' '); + sb.Append(nextLine.Trim()); + i++; + } + else + { + break; + } + } + description = sb.ToString(); + } + else + { + description = rawValue; + } + } } return (name, description); @@ -312,6 +340,8 @@ private static string StripYamlValue(string raw) private static string NormalizePath(string path) { var normalized = path.Replace('\\', '/'); + while (normalized.Contains("/./")) + normalized = normalized.Replace("/./", "/"); while (normalized.StartsWith("./", StringComparison.Ordinal)) normalized = normalized[2..]; while (normalized.Contains("//")) From c9f496f1a0755609444d9d13f5647d23d1315aa6 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Fri, 17 Apr 2026 11:16:13 +0200 Subject: [PATCH 08/31] fix: Windows path use OrdinalIgnoreCase for filesystem pathscompatibility - AgentEnvironmentDetector: git root comparison now case-insensitive (Windows paths are case-insensitive, C:\Users vs c:\users) - MarketplaceClient: path traversal check now case-insensitive (prevents false rejections on Windows with mixed-case paths) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Cli/Microsoft.Maui.Cli/Ai/AgentEnvironmentDetector.cs | 2 +- src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/AgentEnvironmentDetector.cs b/src/Cli/Microsoft.Maui.Cli/Ai/AgentEnvironmentDetector.cs index ad1654cb6..b2592082d 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/AgentEnvironmentDetector.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/AgentEnvironmentDetector.cs @@ -73,7 +73,7 @@ public static List Detect(string workingDir) // Stop at the Git root. if (rootFullPath is not null && - string.Equals(current.FullName, rootFullPath, StringComparison.Ordinal)) + string.Equals(current.FullName, rootFullPath, StringComparison.OrdinalIgnoreCase)) break; current = current.Parent; diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs b/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs index a1e688018..78cb1a0b1 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs @@ -198,7 +198,7 @@ public static async Task DownloadSkillFilesAsync( // Validate the resolved path stays under the destination directory. var fullDest = Path.GetFullPath(destPath); - if (!fullDest.StartsWith(fullBase, StringComparison.Ordinal)) + if (!fullDest.StartsWith(fullBase, StringComparison.OrdinalIgnoreCase)) continue; var destFileDir = Path.GetDirectoryName(destPath); From fc21b52126701cb40b27497fcd5c90843229da72 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Fri, 17 Apr 2026 11:25:23 +0200 Subject: [PATCH 09/31] test: Add 12 filesystem integration tests for maui ai End-to-end tests without network calls using MockHttpMessageHandler: - Environment detection (Claude + VsCode) with path verification - SkillVersionStore round-trip with all fields - MCP config: Claude schema, OpenCode schema, existing entries preserved, idempotent re-runs - YAML block scalar parsing (>- multi-line descriptions) - Real SKILL.md frontmatter parsing - NormalizePath middle './' handling - Full install simulation with mock HTTP - Dry-run skip when version exists - Invalid skill name rejection (../malicious) All 12 tests pass offline in <0.6s. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AiIntegrationTests.cs | 579 ++++++++++++++++++ 1 file changed, 579 insertions(+) create mode 100644 src/Cli/Microsoft.Maui.Cli.UnitTests/AiIntegrationTests.cs diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/AiIntegrationTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/AiIntegrationTests.cs new file mode 100644 index 000000000..0d59c396a --- /dev/null +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/AiIntegrationTests.cs @@ -0,0 +1,579 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Maui.Cli.Ai; +using Microsoft.Maui.Cli.Ai.Models; +using Xunit; + +namespace Microsoft.Maui.Cli.UnitTests; + +/// +/// End-to-end integration tests for the maui ai feature that exercise +/// full filesystem flows without any network calls. Uses real SKILL.md content, +/// real directory structures, and verifies file creation, version tracking, +/// MCP config, and idempotency. +/// +public class AiIntegrationTests : IDisposable +{ + private readonly string _tempRoot; + + public AiIntegrationTests() + { + _tempRoot = Path.Combine(Path.GetTempPath(), "maui-ai-integration-" + Path.GetRandomFileName()); + Directory.CreateDirectory(_tempRoot); + } + + public void Dispose() + { + if (Directory.Exists(_tempRoot)) + Directory.Delete(_tempRoot, recursive: true); + } + + // ────────────────────────────────────────────────────────────────────── + // 1. Environment Detection → Full Cycle + // ────────────────────────────────────────────────────────────────────── + + [Fact] + public void EnvironmentDetection_ClaudeAndVsCode_FullCycle() + { + var projectDir = Path.Combine(_tempRoot, "project1"); + Directory.CreateDirectory(Path.Combine(projectDir, ".claude")); + Directory.CreateDirectory(Path.Combine(projectDir, ".vscode")); + + var environments = AgentEnvironmentDetector.Detect(projectDir); + + var claude = Assert.Single(environments, e => e.Kind == AgentEnvironmentKind.Claude); + var vscode = Assert.Single(environments, e => e.Kind == AgentEnvironmentKind.VsCode); + + // Paths are absolute + Assert.True(Path.IsPathRooted(claude.SkillsDirectory)); + Assert.True(Path.IsPathRooted(claude.McpConfigPath)); + Assert.True(Path.IsPathRooted(vscode.SkillsDirectory)); + Assert.True(Path.IsPathRooted(vscode.McpConfigPath)); + + // Paths point to the expected locations + Assert.Equal(Path.Combine(projectDir, ".claude", "skills"), claude.SkillsDirectory); + Assert.Equal(Path.Combine(projectDir, ".claude", "mcp.json"), claude.McpConfigPath); + Assert.Equal(Path.Combine(projectDir, ".github", "skills"), vscode.SkillsDirectory); + Assert.Equal(Path.Combine(projectDir, ".vscode", "mcp.json"), vscode.McpConfigPath); + } + + // ────────────────────────────────────────────────────────────────────── + // 2. SkillVersionStore Round-Trip with Real Content + // ────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task SkillVersionStore_RoundTrip_AllFieldsPreserved() + { + var skillDir = Path.Combine(_tempRoot, "versions", "devflow-connect"); + var version = new InstalledSkillVersion + { + Name = "devflow-connect", + Commit = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + Branch = "main", + UpdatedAt = "2025-07-14T10:30:00.0000000Z", + Source = "dotnet/maui-labs", + PluginPath = ".github/plugins/dotnet-maui/skills/devflow-connect" + }; + + await SkillVersionStore.WriteAsync(skillDir, version); + var result = await SkillVersionStore.ReadAsync(skillDir); + + Assert.NotNull(result); + Assert.Equal(version.Name, result.Name); + Assert.Equal(version.Commit, result.Commit); + Assert.Equal(version.Branch, result.Branch); + Assert.Equal(version.UpdatedAt, result.UpdatedAt); + Assert.Equal(version.Source, result.Source); + Assert.Equal(version.PluginPath, result.PluginPath); + + // File is valid JSON + var filePath = Path.Combine(skillDir, ".skill-version"); + Assert.True(File.Exists(filePath)); + var json = await File.ReadAllTextAsync(filePath); + var node = JsonNode.Parse(json); + Assert.NotNull(node); + + // File is in the correct location + Assert.StartsWith(skillDir, Path.GetDirectoryName(filePath)!); + } + + // ────────────────────────────────────────────────────────────────────── + // 3. McpConfigurator Creates Correct Claude Config + // ────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task McpConfigurator_Claude_CreatesCorrectConfig() + { + var projectDir = Path.Combine(_tempRoot, "claude-config"); + var claudeDir = Path.Combine(projectDir, ".claude"); + Directory.CreateDirectory(claudeDir); + var configPath = Path.Combine(claudeDir, "mcp.json"); + + var env = new DetectedEnvironment + { + Kind = AgentEnvironmentKind.Claude, + SkillsDirectory = Path.Combine(claudeDir, "skills"), + McpConfigPath = configPath, + McpConfigExists = false + }; + + var result = await McpConfigurator.ConfigureAsync(env); + + Assert.True(result); + Assert.True(File.Exists(configPath)); + + var json = JsonNode.Parse(await File.ReadAllTextAsync(configPath)); + Assert.NotNull(json); + + var server = json["mcpServers"]?["maui-devflow"]; + Assert.NotNull(server); + Assert.Equal("maui", server["command"]?.GetValue()); + + var args = server["args"]?.AsArray(); + Assert.NotNull(args); + Assert.Equal(2, args.Count); + Assert.Equal("devflow", args[0]?.GetValue()); + Assert.Equal("mcp", args[1]?.GetValue()); + } + + // ────────────────────────────────────────────────────────────────────── + // 4. McpConfigurator Creates Correct OpenCode Config + // ────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task McpConfigurator_OpenCode_CreatesCorrectConfig() + { + var projectDir = Path.Combine(_tempRoot, "opencode-config"); + var openCodeDir = Path.Combine(projectDir, ".opencode"); + Directory.CreateDirectory(openCodeDir); + var configPath = Path.Combine(openCodeDir, "config.json"); + + var env = new DetectedEnvironment + { + Kind = AgentEnvironmentKind.OpenCode, + SkillsDirectory = Path.Combine(openCodeDir, "skills"), + McpConfigPath = configPath, + McpConfigExists = false + }; + + var result = await McpConfigurator.ConfigureAsync(env); + + Assert.True(result); + Assert.True(File.Exists(configPath)); + + var json = JsonNode.Parse(await File.ReadAllTextAsync(configPath)); + Assert.NotNull(json); + + // OpenCode uses mcp.servers (different schema from Claude's mcpServers) + var server = json["mcp"]?["servers"]?["maui-devflow"]; + Assert.NotNull(server); + Assert.Equal("maui", server["command"]?.GetValue()); + + var args = server["args"]?.AsArray(); + Assert.NotNull(args); + Assert.Equal(2, args.Count); + Assert.Equal("devflow", args[0]?.GetValue()); + Assert.Equal("mcp", args[1]?.GetValue()); + + // Verify it does NOT use the standard mcpServers key + Assert.Null(json["mcpServers"]); + } + + // ────────────────────────────────────────────────────────────────────── + // 5. McpConfigurator Preserves Existing Entries + // ────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task McpConfigurator_PreservesExistingEntries() + { + var projectDir = Path.Combine(_tempRoot, "preserve-entries"); + var claudeDir = Path.Combine(projectDir, ".claude"); + Directory.CreateDirectory(claudeDir); + var configPath = Path.Combine(claudeDir, "mcp.json"); + + // Write an existing config with a custom server entry + var existing = new JsonObject + { + ["mcpServers"] = new JsonObject + { + ["my-custom-server"] = new JsonObject + { + ["command"] = "custom-tool", + ["args"] = new JsonArray("serve", "--port", "9999") + } + } + }; + await File.WriteAllTextAsync(configPath, existing.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); + + var env = new DetectedEnvironment + { + Kind = AgentEnvironmentKind.Claude, + SkillsDirectory = Path.Combine(claudeDir, "skills"), + McpConfigPath = configPath, + McpConfigExists = true + }; + + var result = await McpConfigurator.ConfigureAsync(env); + + Assert.True(result); + + var json = JsonNode.Parse(await File.ReadAllTextAsync(configPath)); + var servers = json?["mcpServers"]?.AsObject(); + Assert.NotNull(servers); + + // Custom entry still present + var custom = servers["my-custom-server"]; + Assert.NotNull(custom); + Assert.Equal("custom-tool", custom["command"]?.GetValue()); + + // maui-devflow added alongside it + var maui = servers["maui-devflow"]; + Assert.NotNull(maui); + Assert.Equal("maui", maui["command"]?.GetValue()); + } + + // ────────────────────────────────────────────────────────────────────── + // 6. McpConfigurator Idempotent — Run Twice + // ────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task McpConfigurator_Idempotent_RunTwice() + { + var projectDir = Path.Combine(_tempRoot, "idempotent"); + var claudeDir = Path.Combine(projectDir, ".claude"); + Directory.CreateDirectory(claudeDir); + var configPath = Path.Combine(claudeDir, "mcp.json"); + + var env = new DetectedEnvironment + { + Kind = AgentEnvironmentKind.Claude, + SkillsDirectory = Path.Combine(claudeDir, "skills"), + McpConfigPath = configPath, + McpConfigExists = false + }; + + // First run + var result1 = await McpConfigurator.ConfigureAsync(env); + Assert.True(result1); + var contentAfterFirst = await File.ReadAllTextAsync(configPath); + + // Second run + var result2 = await McpConfigurator.ConfigureAsync(env); + Assert.True(result2); + var contentAfterSecond = await File.ReadAllTextAsync(configPath); + + // File unchanged — no duplicate entries + Assert.Equal(contentAfterFirst, contentAfterSecond); + + // Verify only one maui-devflow entry exists + var json = JsonNode.Parse(contentAfterSecond); + var servers = json?["mcpServers"]?.AsObject(); + Assert.NotNull(servers); + var mauiEntries = servers.Where(kv => kv.Key == "maui-devflow").ToList(); + Assert.Single(mauiEntries); + } + + // ────────────────────────────────────────────────────────────────────── + // 7. ParseFrontmatter with YAML Block Scalar + // ────────────────────────────────────────────────────────────────────── + + [Fact] + public void ParseFrontmatter_YamlBlockScalar_JoinsWithSpaces() + { + var content = "---\nname: block-scalar-skill\ndescription: >-\n multi\n line\n text\n---\n# Body\n"; + + var (name, description) = MarketplaceClient.ParseFrontmatter(content); + + Assert.Equal("block-scalar-skill", name); + Assert.Equal("multi line text", description); + } + + // ────────────────────────────────────────────────────────────────────── + // 8. ParseFrontmatter with Real SKILL.md from Marketplace + // ────────────────────────────────────────────────────────────────────── + + [Fact] + public void ParseFrontmatter_RealSkillMd_DevflowConnect() + { + var content = """ + --- + name: devflow-connect + description: >- + Diagnose and fix DevFlow agent connectivity issues between the maui CLI + and running .NET MAUI apps. USE FOR: "maui devflow" connection failures, + agent not found, port conflicts, adb forwarding issues on Android, + broker discovery problems. + --- + # DevFlow Connect Troubleshooter + + This skill helps diagnose and resolve connectivity issues with the + .NET MAUI DevFlow agent. + """; + + var (name, description) = MarketplaceClient.ParseFrontmatter(content); + + Assert.Equal("devflow-connect", name); + Assert.NotNull(description); + Assert.Contains("DevFlow agent connectivity", description); + } + + // ────────────────────────────────────────────────────────────────────── + // 9. NormalizePath Handles Middle './' Segments (tested indirectly) + // ────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task NormalizePath_MiddleDotSlash_HandledIndirectly() + { + // NormalizePath is private, so we test it indirectly via GetPluginAsync. + // When pluginSourcePath contains "./" segments, the URL should be cleaned. + // We set up a mock handler that captures the requested URL. + string? capturedUrl = null; + + var handler = new UrlCapturingHandler(url => + { + capturedUrl = url; + // Return a valid plugin.json response + var pluginJson = """{"name":"test-plugin","skills":["skills"]}"""; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(pluginJson, Encoding.UTF8, "application/json") + }; + }); + + using var http = new HttpClient(handler); + + // The path "plugins/dotnet-maui/./skills/" should be normalized to "plugins/dotnet-maui/skills" + await MarketplaceClient.GetPluginAsync(http, "owner/repo", "main", "plugins/dotnet-maui/./skills/"); + + Assert.NotNull(capturedUrl); + // The URL should NOT contain "/./" — the path should be normalized + Assert.DoesNotContain("/./", capturedUrl); + // The URL should contain the normalized path + Assert.Contains("plugins/dotnet-maui/skills/plugin.json", capturedUrl); + } + + // ────────────────────────────────────────────────────────────────────── + // 10. Full Init Simulation (filesystem only) + // ────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task FullInitSimulation_InstallsSkillWithMockHttp() + { + var projectDir = Path.Combine(_tempRoot, "full-init"); + var skillsDir = Path.Combine(projectDir, ".claude", "skills"); + Directory.CreateDirectory(Path.Combine(projectDir, ".claude")); + + var skillMdContent = """ + --- + name: devflow-connect + description: Diagnose DevFlow agent connectivity issues + --- + # DevFlow Connect + + Use this skill to troubleshoot DevFlow agent connections. + """; + + var commitJson = """[{"sha":"abc123def456"}]"""; + + var handler = new MockHttpMessageHandler(); + handler.AddResponse("raw.githubusercontent.com", skillMdContent); + handler.AddResponse("api.github.com/repos/dotnet/maui-labs/commits", commitJson); + + using var http = new HttpClient(handler); + + var skill = new SkillInfo + { + Name = "devflow-connect", + Description = "Diagnose DevFlow agent connectivity issues", + PluginName = "dotnet-maui", + RemotePath = ".github/plugins/dotnet-maui/skills/devflow-connect", + Files = [".github/plugins/dotnet-maui/skills/devflow-connect/SKILL.md"] + }; + + var env = new DetectedEnvironment + { + Kind = AgentEnvironmentKind.Claude, + SkillsDirectory = skillsDir, + McpConfigPath = Path.Combine(projectDir, ".claude", "mcp.json"), + McpConfigExists = false + }; + + var (filesInstalled, installPath) = await SkillInstaller.InstallSkillAsync( + http, skill, env, projectDir, "dotnet/maui-labs", "main", force: true); + + Assert.Equal(1, filesInstalled); + Assert.Equal(Path.Combine(skillsDir, "devflow-connect"), installPath); + + // SKILL.md exists with correct content + var skillMdPath = Path.Combine(installPath, "SKILL.md"); + Assert.True(File.Exists(skillMdPath)); + var writtenContent = await File.ReadAllTextAsync(skillMdPath); + Assert.Contains("DevFlow Connect", writtenContent); + + // .skill-version exists + var versionPath = Path.Combine(installPath, ".skill-version"); + Assert.True(File.Exists(versionPath)); + + var version = await SkillVersionStore.ReadAsync(installPath); + Assert.NotNull(version); + Assert.Equal("devflow-connect", version.Name); + Assert.Equal("abc123def456", version.Commit); + Assert.Equal("main", version.Branch); + Assert.Equal("dotnet/maui-labs", version.Source); + } + + // ────────────────────────────────────────────────────────────────────── + // 11. Dry-Run — SkillInstaller with force=false and existing version + // ────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task SkillInstaller_DryRun_SkipsWhenVersionExists() + { + var projectDir = Path.Combine(_tempRoot, "dry-run"); + var skillsDir = Path.Combine(projectDir, ".claude", "skills"); + var skillDir = Path.Combine(skillsDir, "devflow-connect"); + Directory.CreateDirectory(skillDir); + + // Write .skill-version manually to simulate a previous install + var version = new InstalledSkillVersion + { + Name = "devflow-connect", + Commit = "existing-commit-sha", + Branch = "main", + UpdatedAt = "2025-07-01T00:00:00Z", + Source = "dotnet/maui-labs", + PluginPath = ".github/plugins/dotnet-maui/skills/devflow-connect" + }; + await SkillVersionStore.WriteAsync(skillDir, version); + + // Use a handler that records whether any request was made + var handler = new RequestTrackingHandler(); + using var http = new HttpClient(handler); + + var skill = new SkillInfo + { + Name = "devflow-connect", + RemotePath = ".github/plugins/dotnet-maui/skills/devflow-connect", + Files = [".github/plugins/dotnet-maui/skills/devflow-connect/SKILL.md"] + }; + + var env = new DetectedEnvironment + { + Kind = AgentEnvironmentKind.Claude, + SkillsDirectory = skillsDir, + McpConfigPath = Path.Combine(projectDir, ".claude", "mcp.json") + }; + + var (filesInstalled, installPath) = await SkillInstaller.InstallSkillAsync( + http, skill, env, projectDir, "dotnet/maui-labs", "main", force: false); + + // Returns (0, path) — skipped + Assert.Equal(0, filesInstalled); + Assert.Equal(skillDir, installPath); + + // No HTTP calls made + Assert.Equal(0, handler.RequestCount); + } + + // ────────────────────────────────────────────────────────────────────── + // 12. Invalid Skill Name Rejected + // ────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task SkillInstaller_InvalidSkillName_Rejected() + { + var projectDir = Path.Combine(_tempRoot, "invalid-name"); + var skillsDir = Path.Combine(projectDir, ".claude", "skills"); + Directory.CreateDirectory(Path.Combine(projectDir, ".claude")); + + var skill = new SkillInfo + { + Name = "../malicious", + RemotePath = "some/remote/path", + Files = ["some/remote/path/SKILL.md"] + }; + + var env = new DetectedEnvironment + { + Kind = AgentEnvironmentKind.Claude, + SkillsDirectory = skillsDir, + McpConfigPath = Path.Combine(projectDir, ".claude", "mcp.json") + }; + + using var http = new HttpClient(); + + var (filesInstalled, installPath) = await SkillInstaller.InstallSkillAsync( + http, skill, env, projectDir, "owner/repo", "main", force: false); + + // Returns (-1, "") — rejected before any file operations + Assert.Equal(-1, filesInstalled); + Assert.Equal(string.Empty, installPath); + + // No files created + Assert.False(Directory.Exists(skillsDir)); + } + + // ────────────────────────────────────────────────────────────────────── + // Test helper: MockHttpMessageHandler + // ────────────────────────────────────────────────────────────────────── + + /// + /// Returns predefined responses based on URL substring matching. + /// Avoids any real network calls while testing the full install pipeline. + /// + private sealed class MockHttpMessageHandler : HttpMessageHandler + { + private readonly Dictionary _responses = new(); + + public void AddResponse(string urlContains, string content, HttpStatusCode status = HttpStatusCode.OK) + => _responses[urlContains] = (Encoding.UTF8.GetBytes(content), status); + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + foreach (var (key, value) in _responses) + { + if (request.RequestUri?.ToString().Contains(key) == true) + return Task.FromResult(new HttpResponseMessage(value.Status) + { Content = new ByteArrayContent(value.Content) }); + } + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); + } + } + + /// + /// Tracks whether any HTTP requests were made, returns 404 for all. + /// + private sealed class RequestTrackingHandler : HttpMessageHandler + { + public int RequestCount { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + RequestCount++; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); + } + } + + /// + /// Captures the requested URL and returns a configurable response. + /// Used to verify URL normalization behavior indirectly. + /// + private sealed class UrlCapturingHandler : HttpMessageHandler + { + private readonly Func _responseFactory; + + public UrlCapturingHandler(Func responseFactory) + => _responseFactory = responseFactory; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + var url = request.RequestUri?.ToString() ?? string.Empty; + return Task.FromResult(_responseFactory(url)); + } + } +} From b3f158d150e5ebf5772857ba3d56453c35a37d63 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Fri, 17 Apr 2026 12:10:15 +0200 Subject: [PATCH 10/31] fix: Address second PR review error propagation, status accuracyround - FetchStringAsync/FetchBytesAsync: remove HttpRequestException swallow, only return null for 404; other errors propagate to command handlers - Status --check-updates: show 'Unknown' instead of 'Up to date' when remote SHA unavailable - Update: track uncheckable skills, warn user, return exit 1 in CI mode - McpConfigurator: return false when existing config keys are wrong type instead of silently overwriting - SkillInstallerTests: replace real HttpClient with mock handler Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../SkillInstallerTests.cs | 33 +++++++++---------- .../Ai/MarketplaceClient.cs | 12 ++----- .../Microsoft.Maui.Cli/Ai/McpConfigurator.cs | 18 ++++++++-- .../Commands/AiCommands.Status.cs | 2 +- .../Commands/AiCommands.Update.cs | 26 +++++++++++---- 5 files changed, 54 insertions(+), 37 deletions(-) diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs index b6dc8380e..08177c0e7 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs @@ -73,25 +73,24 @@ public async Task InstallSkillAsync_ValidName_DoesNotReturnNegativeOne() SkillsDirectory = Path.Combine(_tempDir, "skills") }; - // This will fail at the HTTP level (no real server), but it should NOT - // return -1 because the name validation passes. We expect either an - // exception from the HTTP call or a -2 (download returned 0 files). - try - { - var (filesInstalled, _) = await SkillInstaller.InstallSkillAsync( - new HttpClient(), skill, env, _tempDir, "owner/repo", "main", force: false); + // Use a mock handler that returns 404 for all requests so no real + // network calls are made. The install should pass name validation + // and return 0 or -2 (no files downloaded), but never -1 (invalid name). + var handler = new NotFoundHandler(); + using var http = new HttpClient(handler); - // If it didn't throw, it should not be -1 (invalid name) - Assert.NotEqual(-1, filesInstalled); - } - catch (HttpRequestException) - { - // Expected: the HttpClient has no BaseAddress so the HTTP call fails. - // The important thing is we got past the name validation. - } - catch (InvalidOperationException) + var (filesInstalled, _) = await SkillInstaller.InstallSkillAsync( + http, skill, env, _tempDir, "owner/repo", "main", force: false); + + Assert.NotEqual(-1, filesInstalled); + } + + private sealed class NotFoundHandler : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) { - // Also acceptable: HttpClient may throw this without a BaseAddress. + return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.NotFound)); } } } diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs b/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs index 78cb1a0b1..b3a159fb6 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs @@ -355,8 +355,7 @@ private static string NormalizePath(string path) { using var response = await http.GetAsync(url, ct).ConfigureAwait(false); - // Return null for 404 (resource not found) and network errors; - // user cancellation (Ctrl+C) propagates via OperationCanceledException. + // Return null only for 404; other HTTP errors propagate to callers. if (response.StatusCode == System.Net.HttpStatusCode.NotFound) return null; @@ -364,10 +363,6 @@ private static string NormalizePath(string path) return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); } - catch (HttpRequestException) - { - return null; - } catch (TaskCanceledException) when (!ct.IsCancellationRequested) { return null; // real HTTP timeout @@ -380,6 +375,7 @@ private static string NormalizePath(string path) { using var response = await http.GetAsync(url, ct).ConfigureAwait(false); + // Return null only for 404; other HTTP errors propagate to callers. if (response.StatusCode == System.Net.HttpStatusCode.NotFound) return null; @@ -387,10 +383,6 @@ private static string NormalizePath(string path) return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false); } - catch (HttpRequestException) - { - return null; - } catch (TaskCanceledException) when (!ct.IsCancellationRequested) { return null; // real HTTP timeout diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs b/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs index 2be12bc75..06b7321b4 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs @@ -90,7 +90,11 @@ public static async Task ConfigureAsync(DetectedEnvironment env, Cancellat /// true if the entry already exists (no changes needed). private static bool EnsureStandardEntry(JsonObject root, JsonObject serverEntry) { - if (root["mcpServers"] is not JsonObject mcpServers) + var existing = root["mcpServers"]; + if (existing is not null and not JsonObject) + return false; + + if (existing is not JsonObject mcpServers) { mcpServers = new JsonObject(); root["mcpServers"] = mcpServers; @@ -109,13 +113,21 @@ private static bool EnsureStandardEntry(JsonObject root, JsonObject serverEntry) /// true if the entry already exists (no changes needed). private static bool EnsureOpenCodeEntry(JsonObject root, JsonObject serverEntry) { - if (root["mcp"] is not JsonObject mcp) + var existingMcp = root["mcp"]; + if (existingMcp is not null and not JsonObject) + return false; + + if (existingMcp is not JsonObject mcp) { mcp = new JsonObject(); root["mcp"] = mcp; } - if (mcp["servers"] is not JsonObject servers) + var existingServers = mcp["servers"]; + if (existingServers is not null and not JsonObject) + return false; + + if (existingServers is not JsonObject servers) { servers = new JsonObject(); mcp["servers"] = servers; diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Status.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Status.cs index f2940bad2..f862d4f77 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Status.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Status.cs @@ -83,7 +83,7 @@ static Command CreateStatusCommand() } else { - status = "Up to date"; + status = "Unknown"; } } diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs index 53e63a2a1..c68c0f892 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs @@ -60,6 +60,7 @@ static Command CreateUpdateCommand() // so environments sharing the same skills directory are not updated twice. var updatable = new List<(DetectedEnvironment Env, string SkillDir, string SkillName, InstalledSkillVersion Version)>(); var processedPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + var uncheckableCount = 0; foreach (var env in environments) { @@ -88,13 +89,18 @@ static Command CreateUpdateCommand() var remoteSha = await MarketplaceClient.GetRemoteCommitShaAsync( http, repo, branch, version.PluginPath, ct); - // Only update when the remote SHA is known AND differs from local, - // unless --force. When remoteSha is null (e.g. GitHub unreachable) - // we skip the update to avoid unnecessary reinstalls. + if (remoteSha is null) + { + uncheckableCount++; + if (force) + updatable.Add((env, skillDir, skillName, version)); + continue; + } + + // Only update when the remote SHA differs from local, unless --force. var needsUpdate = force || - (remoteSha is not null && - (version.Commit is null || - !string.Equals(remoteSha, version.Commit, StringComparison.OrdinalIgnoreCase))); + version.Commit is null || + !string.Equals(remoteSha, version.Commit, StringComparison.OrdinalIgnoreCase); if (needsUpdate) updatable.Add((env, skillDir, skillName, version)); @@ -194,6 +200,14 @@ static Command CreateUpdateCommand() formatter.Write(jsonResult); } + if (uncheckableCount > 0) + { + var skillWord = uncheckableCount == 1 ? "skill" : "skill(s)"; + formatter.WriteWarning($"⚠ Could not check {uncheckableCount} {skillWord} — GitHub may be unreachable."); + if (isCi) + return 1; + } + return 0; } catch (HttpRequestException ex) From 522f5811ea60e69937da2bc468eb861ea538b17a Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Thu, 21 May 2026 09:42:29 +0200 Subject: [PATCH 11/31] Extend MAUI AI status and update coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 18 +- .../AiCommandsTests.cs | 46 +++++ .../RepositoryAssetInstallerTests.cs | 54 ++++++ .../Ai/RepositoryAssetInstaller.cs | 58 +++++- .../Commands/AiCommands.AssetStatus.cs | 180 ++++++++++++++++++ .../Commands/AiCommands.Status.cs | 76 +++----- .../Commands/AiCommands.Update.cs | 167 +++++++++++++--- src/Cli/README.md | 28 ++- 8 files changed, 532 insertions(+), 95 deletions(-) create mode 100644 src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.AssetStatus.cs diff --git a/README.md b/README.md index b9a7f8fda..4dcda2ac1 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ A command-line tool for .NET MAUI development environment setup, device manageme - **Apple platform management** (`maui apple`) — Xcode, simulator, and runtime management (macOS) - **Device listing** (`maui device list`) across all connected platforms - **DevFlow app automation** (`maui devflow`) — visual tree inspection, element interaction, screenshots, WebView/CDP automation, network monitoring, profiling, storage access, real-time log/sensor streaming, and MCP server for AI agents +- **AI-powered development bootstrap** (`maui ai init`) — install MAUI Copilot skills, DevFlow skills, Copilot agents, and MCP configuration for the current project - **MAUI Go** (`maui go`) — create, serve, and upgrade single-file Comet Go projects for rapid prototyping - **Version info** (`maui version`) - **Global options** — `--json` for CI pipelines, `--verbose`, `--dry-run`, `--ci` @@ -142,14 +143,27 @@ Built artifacts are exposed as `@(MauiAppArtifact)` items with `ArtifactType`, ` ## Agent Skills -This repository is also a marketplace for distributable agent skills for .NET MAUI development. Skills are organized as plugins compatible with Copilot CLI, Claude Code, and VS Code. +This repository is also a marketplace for distributable agent skills for .NET MAUI development. The recommended one-stop setup is `maui ai init`, which installs the relevant MAUI skills, bundled DevFlow skills, Copilot agent definitions, and MCP configuration for detected agent environments. | Plugin | Description | |--------|-------------| | [`dotnet-maui`](plugins/dotnet-maui/) | MAUI development: DevFlow automation, profiling, accessibility, platform bindings, diagnostics, session review | ```bash -# Install via Copilot CLI +# Preview what will be installed and configured +maui ai init --dry-run + +# Bootstrap this project for AI-powered MAUI development +maui ai init + +# Check and refresh installed AI development assets +maui ai status --check-updates +maui ai update +``` + +Direct plugin installation remains available for agent runtimes that support plugin marketplaces: + +```bash /plugin marketplace add dotnet/maui-labs /plugin install dotnet-maui@dotnet-maui-labs ``` diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs index c9c0022e6..e0e1f7e1f 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.CommandLine; +using System.Text.Json.Nodes; using Microsoft.Maui.Cli.Ai.Models; using Microsoft.Maui.Cli.Commands; using Xunit; @@ -204,6 +205,51 @@ public void GetDevFlowBootstrapTargets_OpenCode_UsesCustomPath() Assert.Equal(Path.Combine(".opencode", "skills"), target.CustomPath); } + [Fact] + public void GetDevFlowStatusRows_MapsBundledSkillStatus() + { + var result = new JsonObject + { + ["skills"] = new JsonArray(new JsonObject + { + ["skillId"] = "maui-devflow-debug", + ["installedVersion"] = "1.0.0", + ["status"] = "up-to-date", + ["path"] = ".github/skills/maui-devflow-debug" + }) + }; + var target = new AiDevFlowBootstrapTarget( + "project", + "github", + null, + "VsCode", + Path.Combine("repo", ".github", "skills")); + + var row = Assert.Single(AiCommands.GetDevFlowStatusRows(result, target)); + + Assert.Equal("maui-devflow-debug", row.Item); + Assert.Equal("DevFlow", row.Type); + Assert.Equal("VsCode", row.Target); + Assert.Equal("1.0.0", row.Installed); + Assert.Equal("up-to-date", row.Status); + Assert.Equal(".github/skills/maui-devflow-debug", row.Path); + } + + [Theory] + [InlineData("up-to-date", false, false)] + [InlineData("Missing", false, true)] + [InlineData("missing", false, true)] + [InlineData("Update available", false, true)] + [InlineData("update-available-from-current-cli", false, true)] + [InlineData("installed-from-newer-cli", false, true)] + [InlineData("up-to-date", true, true)] + public void NeedsUpdate_RecognizesActionableStatuses(string status, bool force, bool expected) + { + var row = new AiCommands.AiAssetStatusRow("item", "Skill", "Claude", "", status, "path"); + + Assert.Equal(expected, AiCommands.NeedsUpdate(row, force)); + } + private static void AssertNoWhitespaceAliases(Command command) { foreach (var option in command.Options) diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/RepositoryAssetInstallerTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/RepositoryAssetInstallerTests.cs index b89ea3427..163fb9a55 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/RepositoryAssetInstallerTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/RepositoryAssetInstallerTests.cs @@ -86,6 +86,60 @@ public async Task InstallAssetAsync_WritesAgentAndSkipsExistingWithoutForce() Assert.Equal(Path.Combine(_tempDir, ".github", "agents"), first.InstallPath); } + [Fact] + public void GetInstalledCopilotAgents_ReturnsOnlyMauiRelatedAgents() + { + var agentsDir = Path.Combine(_tempDir, ".github", "agents"); + Directory.CreateDirectory(agentsDir); + File.WriteAllText(Path.Combine(agentsDir, "expert-reviewer.agent.md"), """ + --- + name: expert-reviewer + description: Expert .NET MAUI DevFlow code reviewer. + --- + # Expert Reviewer + """); + File.WriteAllText(Path.Combine(agentsDir, "generic.agent.md"), """ + --- + name: generic + description: Generic workflow helper. + --- + # Generic + """); + + var assets = RepositoryAssetInstaller.GetInstalledCopilotAgents(_tempDir); + + var asset = Assert.Single(assets); + Assert.Equal("expert-reviewer", asset.Name); + Assert.Equal("agent", asset.Category); + Assert.Equal(".github/agents", asset.DestinationRoot); + } + + [Fact] + public async Task InstallAssetAsync_ForceOverwritesExistingAgent() + { + using var http = new HttpClient(new MapHttpMessageHandler(new Dictionary + { + ["expert-reviewer.agent.md"] = "updated agent content" + })); + var asset = new RepositoryAssetInfo + { + Name = "expert-reviewer", + Category = "agent", + RemotePath = ".github/agents/expert-reviewer.agent.md", + DestinationRoot = ".github/agents", + Files = [".github/agents/expert-reviewer.agent.md"] + }; + var localPath = Path.Combine(_tempDir, ".github", "agents", "expert-reviewer.agent.md"); + Directory.CreateDirectory(Path.GetDirectoryName(localPath)!); + await File.WriteAllTextAsync(localPath, "old content"); + + var result = await RepositoryAssetInstaller.InstallAssetAsync( + http, asset, _tempDir, "owner/repo", "main", force: true); + + Assert.Equal(1, result.FilesInstalled); + Assert.Equal("updated agent content", await File.ReadAllTextAsync(localPath)); + } + sealed class MapHttpMessageHandler(Dictionary responses) : HttpMessageHandler { protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/RepositoryAssetInstaller.cs b/src/Cli/Microsoft.Maui.Cli/Ai/RepositoryAssetInstaller.cs index 5567d1444..1f1dd58e3 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/RepositoryAssetInstaller.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/RepositoryAssetInstaller.cs @@ -10,8 +10,8 @@ namespace Microsoft.Maui.Cli.Ai; /// internal static class RepositoryAssetInstaller { - const string CopilotAgentsRoot = ".github/agents"; - const string CopilotAgentsDestinationRoot = ".github/agents"; + internal const string CopilotAgentsRoot = ".github/agents"; + internal const string CopilotAgentsDestinationRoot = ".github/agents"; /// /// Discovers MAUI-related Copilot agent definitions from .github/agents. @@ -73,17 +73,14 @@ public static async Task> GetCopilotAgentsAsync( bool force, CancellationToken ct = default) { - var destinationRoot = Path.Combine( - projectRoot, - MarketplaceClient.NormalizePath(asset.DestinationRoot).Replace('/', Path.DirectorySeparatorChar)); + var destinationRoot = GetDestinationRoot(projectRoot, asset.DestinationRoot); var destinationBase = Path.GetFullPath(destinationRoot) + Path.DirectorySeparatorChar; Directory.CreateDirectory(destinationRoot); var count = 0; foreach (var filePath in asset.Files) { - var relativePath = GetRemoteFileName(filePath); - var destinationPath = Path.Combine(destinationRoot, relativePath); + var destinationPath = GetAssetFilePath(projectRoot, asset, filePath); var fullDestinationPath = Path.GetFullPath(destinationPath); if (!fullDestinationPath.StartsWith(destinationBase, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)) continue; @@ -102,6 +99,51 @@ public static async Task> GetCopilotAgentsAsync( return (count, destinationRoot); } + /// + /// Discovers MAUI-related Copilot agent definitions already present in the target project. + /// + public static List GetInstalledCopilotAgents(string projectRoot) + { + var destinationRoot = GetDestinationRoot(projectRoot, CopilotAgentsDestinationRoot); + var assets = new List(); + if (!Directory.Exists(destinationRoot)) + return assets; + + foreach (var filePath in Directory.GetFiles(destinationRoot, "*.agent.md", SearchOption.TopDirectoryOnly) + .OrderBy(path => path, StringComparer.Ordinal)) + { + var content = File.ReadAllText(filePath); + var (name, description) = MarketplaceClient.ParseFrontmatter(content); + var fileName = Path.GetFileName(filePath); + var assetName = name ?? fileName[..^".agent.md".Length]; + if (!IsMauiRelatedAgent(assetName, description, content)) + continue; + + assets.Add(new RepositoryAssetInfo + { + Name = assetName, + Category = "agent", + Description = description, + RemotePath = string.Empty, + DestinationRoot = CopilotAgentsDestinationRoot, + Files = [Path.Combine(destinationRoot, fileName)] + }); + } + + return assets; + } + + internal static string GetAssetFilePath(string projectRoot, RepositoryAssetInfo asset, string filePath) + { + var destinationRoot = GetDestinationRoot(projectRoot, asset.DestinationRoot); + return Path.Combine(destinationRoot, GetRemoteFileName(filePath)); + } + + static string GetDestinationRoot(string projectRoot, string destinationRoot) + => Path.Combine( + projectRoot, + MarketplaceClient.NormalizePath(destinationRoot).Replace('/', Path.DirectorySeparatorChar)); + static bool IsMauiRelatedAgent(string name, string? description, string content) { var haystack = string.Join('\n', name, description, content); @@ -109,7 +151,7 @@ static bool IsMauiRelatedAgent(string name, string? description, string content) haystack.Contains("comet", StringComparison.OrdinalIgnoreCase); } - static string GetRemoteFileName(string path) + internal static string GetRemoteFileName(string path) { var normalized = MarketplaceClient.NormalizePath(path); var slashIndex = normalized.LastIndexOf('/'); diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.AssetStatus.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.AssetStatus.cs new file mode 100644 index 000000000..4446c5edc --- /dev/null +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.AssetStatus.cs @@ -0,0 +1,180 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using System.Text.Json.Nodes; +using Microsoft.Maui.Cli.Ai; +using Microsoft.Maui.Cli.Ai.Models; +using Microsoft.Maui.Cli.DevFlow.Skills; + +namespace Microsoft.Maui.Cli.Commands; + +public static partial class AiCommands +{ + internal sealed record AiAssetStatusRow( + string Item, + string Type, + string Target, + string Installed, + string Status, + string Path); + + static async Task> GetDevFlowStatusRowsAsync( + IEnumerable targets, + CancellationToken ct) + { + var rows = new List(); + foreach (var target in targets) + { + var result = await DevFlowSkillManager.CheckAsync( + target.Scope, + target.Target, + target.CustomPath, + online: false, + ct); + + rows.AddRange(GetDevFlowStatusRows(result, target)); + } + + return rows; + } + + internal static IEnumerable GetDevFlowStatusRows(JsonObject result, AiDevFlowBootstrapTarget target) + { + if (result["skills"] is not JsonArray skills) + yield break; + + foreach (var item in skills.OfType()) + { + var skillId = GetJsonString(item, "skillId") ?? "unknown"; + yield return new AiAssetStatusRow( + skillId, + "DevFlow", + target.DisplayName, + GetJsonString(item, "installedVersion") ?? "", + GetJsonString(item, "status") ?? "unknown", + GetJsonString(item, "path") ?? target.SkillsDirectory); + } + } + + static async Task> GetMarketplaceSkillStatusRowsAsync( + IEnumerable environments, + bool checkUpdates, + HttpClient? http, + string repo, + string branch, + CancellationToken ct) + { + var rows = new List(); + foreach (var env in GetUniqueSkillInstallEnvironments(environments)) + { + if (!Directory.Exists(env.SkillsDirectory)) + continue; + + foreach (var skillDir in Directory.GetDirectories(env.SkillsDirectory).OrderBy(path => path, StringComparer.Ordinal)) + { + var skillName = Path.GetFileName(skillDir); + if (IsDevFlowManagedSkillName(skillName)) + continue; + + var version = await SkillVersionStore.ReadAsync(skillDir, ct).ConfigureAwait(false); + if (version is null) + { + rows.Add(new AiAssetStatusRow(skillName, "Skill", env.Kind.ToString(), "Unknown", "Unknown", skillDir)); + continue; + } + + var installed = FormatInstalledTimestamp(version.UpdatedAt); + var status = "Installed"; + + if (checkUpdates && http is not null && version.PluginPath is not null) + { + var remoteSha = await MarketplaceClient.GetRemoteCommitShaAsync( + http, repo, branch, version.PluginPath, ct).ConfigureAwait(false); + + status = remoteSha is not null && version.Commit is not null + ? string.Equals(remoteSha, version.Commit, StringComparison.OrdinalIgnoreCase) + ? "Up to date" + : "Update available" + : "Unknown"; + } + + rows.Add(new AiAssetStatusRow(skillName, "Skill", env.Kind.ToString(), installed, status, skillDir)); + } + } + + return rows; + } + + internal static List GetInstalledAgentStatusRows(string workingDir) + => RepositoryAssetInstaller.GetInstalledCopilotAgents(workingDir) + .Select(asset => new AiAssetStatusRow( + asset.Name, + asset.Category, + "GitHub Copilot", + "Yes", + "Installed", + Path.Combine(workingDir, asset.DestinationRoot))) + .ToList(); + + internal static async Task> GetRemoteAgentStatusRowsAsync( + HttpClient http, + IEnumerable assets, + string workingDir, + string repo, + string branch, + CancellationToken ct) + { + var rows = new List<(RepositoryAssetInfo Asset, AiAssetStatusRow Row)>(); + foreach (var asset in assets) + { + var status = "Up to date"; + foreach (var filePath in asset.Files) + { + var localPath = RepositoryAssetInstaller.GetAssetFilePath(workingDir, asset, filePath); + if (!File.Exists(localPath)) + { + status = "Missing"; + break; + } + + var remoteBytes = await MarketplaceClient.FetchRawBytesAsync(http, repo, branch, filePath, ct).ConfigureAwait(false); + if (remoteBytes is null) + { + status = "Unknown"; + break; + } + + var localBytes = await File.ReadAllBytesAsync(localPath, ct).ConfigureAwait(false); + if (!remoteBytes.SequenceEqual(localBytes)) + { + status = "Update available"; + break; + } + } + + rows.Add((asset, new AiAssetStatusRow( + asset.Name, + asset.Category, + "GitHub Copilot", + status == "Missing" ? "No" : "Yes", + status, + Path.Combine(workingDir, asset.DestinationRoot)))); + } + + return rows; + } + + internal static bool NeedsUpdate(AiAssetStatusRow row, bool force) + => force || row.Status is "Missing" or "missing" or "Update available" or "update-available-from-current-cli" or "installed-from-different-cli-same-version" or "installed-from-newer-cli" or "dirty" or "unknown-or-unmanaged"; + + static string FormatInstalledTimestamp(string? timestamp) + { + if (timestamp is null) + return "Unknown"; + + return DateTime.TryParse(timestamp, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dt) + ? dt.ToLocalTime().ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) + : timestamp; + } +} diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Status.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Status.cs index f862d4f77..7091f6cad 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Status.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Status.cs @@ -3,11 +3,8 @@ using System.CommandLine; using System.CommandLine.Parsing; -using System.Globalization; using System.Text.Json.Nodes; using Microsoft.Maui.Cli.Ai; -using Microsoft.Maui.Cli.Ai.Models; -using Microsoft.Maui.Cli.Output; namespace Microsoft.Maui.Cli.Commands; @@ -18,7 +15,7 @@ public static partial class AiCommands /// static Command CreateStatusCommand() { - var command = new Command("status", "Show status of installed AI agent skills") + var command = new Command("status", "Show status of installed AI development assets") { CreateRepoOption(), CreateBranchOption(), @@ -46,54 +43,25 @@ static Command CreateStatusCommand() using var http = checkUpdates ? CreateGitHubHttpClient() : null; - var rows = new List<(string Skill, string Env, string Installed, string Status)>(); + var rows = new List(); + rows.AddRange(await GetDevFlowStatusRowsAsync(GetDevFlowBootstrapTargets(environments), ct)); + rows.AddRange(await GetMarketplaceSkillStatusRowsAsync(environments, checkUpdates, http, repo, branch, ct)); - foreach (var env in environments) + if (checkUpdates && http is not null) { - if (!Directory.Exists(env.SkillsDirectory)) - continue; - - foreach (var skillDir in Directory.GetDirectories(env.SkillsDirectory)) - { - var skillName = Path.GetFileName(skillDir); - var version = await SkillVersionStore.ReadAsync(skillDir, ct); - - if (version is null) - { - rows.Add((skillName, env.Kind.ToString(), "Unknown", "Unknown")); - continue; - } - - var installed = version.UpdatedAt is not null - ? (DateTime.TryParse(version.UpdatedAt, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dt) ? dt.ToLocalTime().ToString("yyyy-MM-dd HH:mm") : version.UpdatedAt) - : "Unknown"; - - var status = "Installed"; - - if (checkUpdates && http is not null && version.PluginPath is not null) - { - var remoteSha = await MarketplaceClient.GetRemoteCommitShaAsync( - http, repo, branch, version.PluginPath, ct); - - if (remoteSha is not null && version.Commit is not null) - { - status = string.Equals(remoteSha, version.Commit, StringComparison.OrdinalIgnoreCase) - ? "Up to date" - : "Update available"; - } - else - { - status = "Unknown"; - } - } - - rows.Add((skillName, env.Kind.ToString(), installed, status)); - } + var treeEntries = await MarketplaceClient.FetchTreeEntriesAsync(http, repo, branch, ct); + var agentAssets = await RepositoryAssetInstaller.GetCopilotAgentsAsync(http, repo, branch, treeEntries, ct); + var agentRows = await GetRemoteAgentStatusRowsAsync(http, agentAssets, workingDir, repo, branch, ct); + rows.AddRange(agentRows.Select(row => row.Row)); + } + else + { + rows.AddRange(GetInstalledAgentStatusRows(workingDir)); } if (rows.Count == 0) { - formatter.WriteInfo("No skills installed. Run 'maui ai init' to get started."); + formatter.WriteInfo("No AI development assets found. Run 'maui ai init' to get started."); return 0; } @@ -101,10 +69,12 @@ static Command CreateStatusCommand() { var jsonArray = new JsonArray(rows.Select(r => (JsonNode)new JsonObject { - ["skill"] = r.Skill, - ["environment"] = r.Env, + ["item"] = r.Item, + ["type"] = r.Type, + ["target"] = r.Target, ["installed"] = r.Installed, - ["status"] = r.Status + ["status"] = r.Status, + ["path"] = r.Path }).ToArray()); formatter.Write(jsonArray); } @@ -112,10 +82,12 @@ static Command CreateStatusCommand() { formatter.WriteTable( rows, - ("Skill", r => r.Skill), - ("Environment", r => r.Env), + ("Item", r => r.Item), + ("Type", r => r.Type), + ("Target", r => r.Target), ("Installed", r => r.Installed), - ("Status", r => r.Status)); + ("Status", r => r.Status), + ("Path", r => r.Path)); } return 0; diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs index c68c0f892..3e7c4d3b3 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs @@ -6,6 +6,7 @@ using System.Text.Json.Nodes; using Microsoft.Maui.Cli.Ai; using Microsoft.Maui.Cli.Ai.Models; +using Microsoft.Maui.Cli.DevFlow.Skills; using Microsoft.Maui.Cli.Output; using Spectre.Console; @@ -20,11 +21,11 @@ static Command CreateUpdateCommand() { var skillOption = new Option("--skill") { - Description = "Update only specific skills (repeatable)", + Description = "Update only specific skills or agents (repeatable)", AllowMultipleArgumentsPerToken = true }; - var command = new Command("update", "Update installed AI agent skills to the latest version") + var command = new Command("update", "Update installed AI development assets to the latest version") { CreateRepoOption(), CreateBranchOption(), @@ -56,7 +57,43 @@ static Command CreateUpdateCommand() using var http = CreateGitHubHttpClient(); - // Scan installed skills and check for updates; de-duplicate by resolved path + List allSkills; + List allAgentAssets; + if (!useJson && formatter is SpectreOutputFormatter spectre) + { + (allSkills, allAgentAssets) = await spectre.StatusAsync("Fetching AI assets...", async () => + await FetchBootstrapAssetsAsync(http, repo, branch, ct)); + } + else + { + (allSkills, allAgentAssets) = await FetchBootstrapAssetsAsync(http, repo, branch, ct); + } + + var filterSpecified = skillFilter is { Length: > 0 }; + var includeDevFlowSkills = filterSpecified + ? skillFilter!.Any(IsDevFlowManagedSkillName) + : true; + var devFlowTargets = includeDevFlowSkills + ? GetDevFlowBootstrapTargets(environments) + : []; + var selectedAgentAssets = filterSpecified + ? allAgentAssets + .Where(asset => skillFilter!.Any(filter => string.Equals(filter, asset.Name, StringComparison.OrdinalIgnoreCase))) + .ToList() + : allAgentAssets; + + var devFlowStatusRows = await GetDevFlowStatusRowsAsync(devFlowTargets, ct); + var devFlowTargetsToUpdate = devFlowTargets + .Where(target => devFlowStatusRows + .Where(row => row.Type == "DevFlow" && row.Target == target.DisplayName) + .Any(row => NeedsUpdate(row, force))) + .ToList(); + var agentStatusRows = await GetRemoteAgentStatusRowsAsync(http, selectedAgentAssets, workingDir, repo, branch, ct); + var agentsToUpdate = agentStatusRows + .Where(row => NeedsUpdate(row.Row, force)) + .ToList(); + + // Scan installed marketplace/repository skills and check for updates; de-duplicate by resolved path // so environments sharing the same skills directory are not updated twice. var updatable = new List<(DetectedEnvironment Env, string SkillDir, string SkillName, InstalledSkillVersion Version)>(); var processedPaths = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -74,9 +111,11 @@ static Command CreateUpdateCommand() continue; var skillName = Path.GetFileName(skillDir); + if (IsDevFlowManagedSkillName(skillName)) + continue; - if (skillFilter is { Length: > 0 } && - !skillFilter.Any(f => string.Equals(f, skillName, StringComparison.OrdinalIgnoreCase))) + if (filterSpecified && + !skillFilter!.Any(f => string.Equals(f, skillName, StringComparison.OrdinalIgnoreCase))) continue; var version = await SkillVersionStore.ReadAsync(skillDir, ct); @@ -108,24 +147,51 @@ version.Commit is null || } } - if (updatable.Count == 0) + if (updatable.Count == 0 && devFlowTargetsToUpdate.Count == 0 && agentsToUpdate.Count == 0) { - formatter.WriteSuccess("All skills are up to date."); + formatter.WriteSuccess(filterSpecified ? "All selected AI development assets are up to date." : "All AI development assets are up to date."); + if (uncheckableCount > 0) + { + var skillWord = uncheckableCount == 1 ? "skill" : "skill(s)"; + formatter.WriteWarning($"Could not check {uncheckableCount} {skillWord} — GitHub may be unreachable."); + if (isCi) + return 1; + } + return 0; } - var updateWord = updatable.Count == 1 ? "skill" : "skills"; - formatter.WriteInfo($"Found {updatable.Count} {updateWord} with updates available."); + var totalUpdates = updatable.Count + devFlowTargetsToUpdate.Count + agentsToUpdate.Count; + var updateWord = totalUpdates == 1 ? "AI asset group" : "AI asset groups"; + formatter.WriteInfo($"Found {totalUpdates} {updateWord} with updates available."); + + var updateRows = new List(); + updateRows.AddRange(devFlowTargetsToUpdate.Select(target => new AiAssetStatusRow( + "recommended DevFlow skills", + "DevFlow", + target.DisplayName, + "", + "Update", + target.SkillsDirectory))); + updateRows.AddRange(updatable.Select(u => new AiAssetStatusRow( + u.SkillName, + "Skill", + u.Env.Kind.ToString(), + ShortCommit(u.Version.Commit), + "Update available", + u.SkillDir))); + updateRows.AddRange(agentsToUpdate.Select(row => row.Row)); if (dryRun) { - formatter.WriteInfo("[Dry run] Would update the following skills:"); + formatter.WriteInfo("[Dry run] Would update the following AI development assets:"); formatter.WriteTable( - updatable, - ("Skill", u => u.SkillName), - ("Environment", u => u.Env.Kind.ToString()), - ("Current Commit", u => (u.Version.Commit ?? "unknown")[..Math.Min(u.Version.Commit?.Length ?? 7, 7)]), - ("Path", u => u.SkillDir)); + updateRows, + ("Item", r => r.Item), + ("Type", r => r.Type), + ("Target", r => r.Target), + ("Status", r => r.Status), + ("Path", r => r.Path)); return 0; } @@ -133,10 +199,12 @@ version.Commit is null || if (!force && !isCi && !useJson) { formatter.WriteTable( - updatable, - ("Skill", u => u.SkillName), - ("Environment", u => u.Env.Kind.ToString()), - ("Path", u => u.SkillDir)); + updateRows, + ("Item", r => r.Item), + ("Type", r => r.Type), + ("Target", r => r.Target), + ("Status", r => r.Status), + ("Path", r => r.Path)); if (!AnsiConsole.Confirm("Proceed with update?", defaultValue: true)) { @@ -145,20 +213,28 @@ version.Commit is null || } } - // Fetch marketplace to get skill metadata for re-download - List allSkills; - if (!useJson && formatter is SpectreOutputFormatter spectre) - { - allSkills = await spectre.StatusAsync("Fetching marketplace...", async () => - await FetchAllSkillsAsync(http, repo, branch, ct)); - } - else + var devFlowResults = new List<(string Skill, string Target, string Action, string Path)>(); + var results = new List<(string Skill, string Env, int Files)>(); + var assetResults = new List<(string Asset, string Type, int Files, string Path)>(); + + foreach (var target in devFlowTargetsToUpdate) { - allSkills = await FetchAllSkillsAsync(http, repo, branch, ct); + var result = await DevFlowSkillManager.UpdateAsync( + target.Scope, + target.Target, + target.CustomPath, + force, + allowDowngrade: false, + confirm: null, + ct); + + foreach (var row in GetDevFlowResultRows(result, target)) + { + devFlowResults.Add(row); + formatter.WriteSuccess($"DevFlow {row.Action} {row.Skill} → {row.Target}"); + } } - var results = new List<(string Skill, string Env, int Files)>(); - foreach (var (env, skillDir, skillName, _) in updatable) { var skillInfo = allSkills.FirstOrDefault(s => @@ -185,16 +261,42 @@ version.Commit is null || formatter.WriteInfo($"Skipped {skillName} → {env.Kind} (no files downloaded)"); } + foreach (var (asset, _) in agentsToUpdate) + { + var (filesInstalled, installPath) = await RepositoryAssetInstaller.InstallAssetAsync( + http, asset, workingDir, repo, branch, force: true, ct); + + assetResults.Add((asset.Name, asset.Category, filesInstalled, installPath)); + if (filesInstalled > 0) + formatter.WriteSuccess($"Updated {asset.Name} → {asset.Category} ({filesInstalled} files)"); + else + formatter.WriteWarning($"Could not update {asset.Name} → {asset.Category}"); + } + if (useJson) { var jsonResult = new JsonObject { ["status"] = "success", + ["devFlowSkills"] = new JsonArray(devFlowResults.Select(r => (JsonNode)new JsonObject + { + ["skill"] = r.Skill, + ["target"] = r.Target, + ["action"] = r.Action, + ["path"] = r.Path + }).ToArray()), ["updated"] = new JsonArray(results.Select(r => (JsonNode)new JsonObject { ["skill"] = r.Skill, ["environment"] = r.Env, ["files"] = r.Files + }).ToArray()), + ["assets"] = new JsonArray(assetResults.Select(r => (JsonNode)new JsonObject + { + ["asset"] = r.Asset, + ["type"] = r.Type, + ["files"] = r.Files, + ["path"] = r.Path }).ToArray()) }; formatter.Write(jsonResult); @@ -223,4 +325,9 @@ version.Commit is null || return command; } + + static string ShortCommit(string? commit) + => string.IsNullOrEmpty(commit) + ? "unknown" + : commit[..Math.Min(commit.Length, 7)]; } diff --git a/src/Cli/README.md b/src/Cli/README.md index 1afce4ec3..fbcf5aa42 100644 --- a/src/Cli/README.md +++ b/src/Cli/README.md @@ -28,7 +28,23 @@ maui doctor maui device list ``` -### 3. Manage a project's .NET MAUI version +### 3. Bootstrap AI-powered development + +```bash +# See which skills, Copilot agents, and MCP configs would be installed +maui ai init --dry-run + +# Install MAUI/Copilot skills, bundled DevFlow skills, repo agents, and MCP config +maui ai init + +# Check and refresh everything later +maui ai status --check-updates +maui ai update +``` + +`maui ai init` detects Claude Code, VS Code, Copilot CLI, and OpenCode environments. It delegates DevFlow-owned skills to the bundled DevFlow installer, then installs broader MAUI skills and Copilot agent definitions from this repository. + +### 4. Manage a project's .NET MAUI version ```bash # Show the effective MAUI version for the current project @@ -50,7 +66,7 @@ maui project version set --latest-nightly --nuget-config maui project version use-workload ``` -### 4. Set up Android development +### 5. Set up Android development ```bash # Full interactive Android setup (JDK + SDK + emulator) @@ -68,7 +84,7 @@ maui android emulator create --name MyEmulator maui android emulator start --name MyEmulator ``` -### 5. Set up Apple development (macOS only) +### 6. Set up Apple development (macOS only) ```bash # List installed Xcode versions @@ -96,6 +112,12 @@ maui apple simulator delete "iPhone 16 Pro" | `maui project version set` | Pin a project to a specific, latest stable, nightly, or custom-source MAUI version | | `maui project version use-workload` | Use the installed MAUI workload version instead of a pinned project version | | `maui version` | Display version information | +| **AI** | | +| `maui ai init` | Bootstrap MAUI/Copilot skills, bundled DevFlow skills, Copilot agents, and MCP configuration | +| `maui ai list` | List available marketplace and repository skills | +| `maui ai status` | Show installed AI development asset status, including DevFlow skills and Copilot agents | +| `maui ai update` | Update installed AI development assets, including DevFlow skills and Copilot agents | +| `maui ai add` | Install a specific marketplace or repository skill | | **Android** | | | `maui android install` | Full interactive Android environment setup | | `maui android sdk list` | List available and installed Android SDK packages | From 25bb579581ead220aa430a9888ce80966cdc1132 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Thu, 21 May 2026 11:09:35 +0200 Subject: [PATCH 12/31] Address MAUI AI bootstrap review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MarketplaceClientTests.cs | 119 +++++++++++++++++- .../McpConfiguratorTests.cs | 56 +++++++++ .../SkillInstallerTests.cs | 47 +++++++ .../Ai/MarketplaceClient.cs | 63 +++++++--- .../Microsoft.Maui.Cli/Ai/McpConfigurator.cs | 69 ++++++---- .../Microsoft.Maui.Cli/Ai/SkillInstaller.cs | 53 +++++++- .../Ai/SkillVersionStore.cs | 13 +- 7 files changed, 366 insertions(+), 54 deletions(-) diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs index 8f735b6f6..b91bfaccc 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs @@ -274,6 +274,7 @@ public async Task DownloadSkillFilesAsync_RejectsPathTraversal() Assert.Equal(0, count); // Verify no file was written outside the dest directory Assert.Empty(Directory.GetFiles(_tempDir, "*", SearchOption.AllDirectories)); + Assert.Equal(0, handler.Calls); } [Fact] @@ -297,6 +298,7 @@ public async Task DownloadSkillFilesAsync_RejectsPathTraversal_InRelativePath() Assert.Equal(0, count); Assert.Empty(Directory.GetFiles(_tempDir, "*", SearchOption.AllDirectories)); + Assert.Equal(0, handler.Calls); } [Fact] @@ -320,6 +322,100 @@ public async Task DownloadSkillFilesAsync_AllowsSafeRelativePath() Assert.Equal(1, count); Assert.True(File.Exists(Path.Combine(_tempDir, "SKILL.md"))); + Assert.Equal(1, handler.Calls); + } + + [Fact] + public async Task DownloadSkillFilesAsync_RejectsFilesOutsideRemotePath() + { + var handler = new FakeHttpMessageHandler(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent("wrong skill"u8.ToArray()) + }); + using var http = new HttpClient(handler); + + var skill = new SkillInfo + { + Name = "good-skill", + RemotePath = "plugins/good", + Files = ["plugins/other/SKILL.md"] + }; + + var count = await MarketplaceClient.DownloadSkillFilesAsync( + http, skill, _tempDir, "owner/repo", "main"); + + Assert.Equal(0, count); + Assert.Empty(Directory.GetFiles(_tempDir, "*", SearchOption.AllDirectories)); + Assert.Equal(0, handler.Calls); + } + + [Theory] + [InlineData("plugins/evil/../SKILL.md")] + [InlineData("../plugins/evil/SKILL.md")] + [InlineData("/plugins/evil/SKILL.md")] + [InlineData("plugins\\evil\\..\\SKILL.md")] + public void NormalizePath_UnsafePath_Throws(string path) + { + var exception = Assert.Throws(() => MarketplaceClient.NormalizePath(path)); + Assert.Contains("cannot contain '..' segments", exception.Message); + } + + [Fact] + public async Task FetchTreeEntriesAsync_TruncatedTree_Throws() + { + var handler = new SequenceHttpMessageHandler( + _ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(""" + { + "commit": { + "tree": { "sha": "tree-sha" } + } + } + """) + }, + _ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(""" + { + "truncated": true, + "tree": [] + } + """) + }); + using var http = new HttpClient(handler); + + var exception = await Assert.ThrowsAsync( + () => MarketplaceClient.FetchTreeEntriesAsync(http, "owner/repo", "main")); + + Assert.Contains("truncated", exception.Message); + } + + [Fact] + public async Task FetchRawStringAsync_EncodesRawUrlPathSegments() + { + Uri? requestUri = null; + var handler = new DelegateHttpMessageHandler(request => + { + requestUri = request.RequestUri; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("content") + }; + }); + using var http = new HttpClient(handler); + + await MarketplaceClient.FetchRawStringAsync( + http, + "owner/repo", + "feature/test", + ".github/skills/my skill/SKILL#.md"); + + Assert.NotNull(requestUri); + var url = requestUri!.AbsoluteUri; + Assert.Contains("feature%2Ftest", url); + Assert.Contains("my%20skill", url); + Assert.Contains("SKILL%23.md", url); } /// @@ -328,15 +424,36 @@ public async Task DownloadSkillFilesAsync_AllowsSafeRelativePath() private sealed class FakeHttpMessageHandler : HttpMessageHandler { private readonly HttpResponseMessage _response; + public int Calls { get; private set; } public FakeHttpMessageHandler(HttpResponseMessage response) => _response = response; protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - => Task.FromResult(_response); + { + Calls++; + return Task.FromResult(_response); + } protected override void Dispose(bool disposing) { // Don't dispose _response — the caller owns it via the test scope } } + + private sealed class DelegateHttpMessageHandler(Func handler) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(handler(request)); + } + + private sealed class SequenceHttpMessageHandler(params Func[] handlers) : HttpMessageHandler + { + private int _index; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var handler = handlers[_index++]; + return Task.FromResult(handler(request)); + } + } } diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs index 578379d9f..36cf1d519 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs @@ -242,6 +242,62 @@ public async Task ConfigureAsync_CorruptedJson_ReturnsFalse() Assert.False(result); } + [Fact] + public async Task ConfigureAsync_IncompatibleStandardSchema_ReturnsFalseAndLeavesConfigUnchanged() + { + var configDir = Path.Combine(_tempDir, ".claude"); + Directory.CreateDirectory(configDir); + var configPath = Path.Combine(configDir, "mcp.json"); + var originalContent = """ + { + "mcpServers": [] + } + """; + await File.WriteAllTextAsync(configPath, originalContent); + + var env = new DetectedEnvironment + { + Kind = AgentEnvironmentKind.Claude, + McpConfigPath = configPath, + SkillsDirectory = Path.Combine(_tempDir, ".claude", "skills") + }; + + var result = await McpConfigurator.ConfigureAsync(env); + + Assert.False(result); + Assert.Equal(originalContent, await File.ReadAllTextAsync(configPath)); + Assert.Empty(Directory.EnumerateFiles(configDir, "*.tmp")); + } + + [Fact] + public async Task ConfigureAsync_IncompatibleOpenCodeSchema_ReturnsFalseAndLeavesConfigUnchanged() + { + var configDir = Path.Combine(_tempDir, ".opencode"); + Directory.CreateDirectory(configDir); + var configPath = Path.Combine(configDir, "config.json"); + var originalContent = """ + { + "mcp": { + "servers": [] + } + } + """; + await File.WriteAllTextAsync(configPath, originalContent); + + var env = new DetectedEnvironment + { + Kind = AgentEnvironmentKind.OpenCode, + McpConfigPath = configPath, + SkillsDirectory = Path.Combine(_tempDir, ".opencode", "skills") + }; + + var result = await McpConfigurator.ConfigureAsync(env); + + Assert.False(result); + Assert.Equal(originalContent, await File.ReadAllTextAsync(configPath)); + Assert.Empty(Directory.EnumerateFiles(configDir, "*.tmp")); + } + [Fact] public async Task ConfigureAsync_ReturnsTrue_WhenEntryAlreadyExists() { diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs index 08177c0e7..f4e00e736 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs @@ -85,6 +85,36 @@ public async Task InstallSkillAsync_ValidName_DoesNotReturnNegativeOne() Assert.NotEqual(-1, filesInstalled); } + [Fact] + public async Task InstallSkillAsync_PartialDownload_RollsBackAndReturnsNegativeTwo() + { + var skill = new SkillInfo + { + Name = "partial-skill", + RemotePath = ".github/skills/partial-skill", + Files = + [ + ".github/skills/partial-skill/SKILL.md", + ".github/skills/partial-skill/references/setup.md" + ] + }; + var skillsDir = Path.Combine(_tempDir, "skills"); + var env = new DetectedEnvironment + { + Kind = AgentEnvironmentKind.Claude, + SkillsDirectory = skillsDir + }; + var handler = new SelectiveNotFoundHandler("SKILL.md"); + using var http = new HttpClient(handler); + + var (filesInstalled, installPath) = await SkillInstaller.InstallSkillAsync( + http, skill, env, _tempDir, "owner/repo", "main", force: false); + + Assert.Equal(-2, filesInstalled); + Assert.False(Directory.Exists(installPath)); + Assert.Empty(Directory.EnumerateFileSystemEntries(skillsDir)); + } + private sealed class NotFoundHandler : HttpMessageHandler { protected override Task SendAsync( @@ -93,4 +123,21 @@ protected override Task SendAsync( return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.NotFound)); } } + + private sealed class SelectiveNotFoundHandler(string successfulFileName) : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.RequestUri?.AbsolutePath.EndsWith(successfulFileName, StringComparison.Ordinal) == true) + { + return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new ByteArrayContent("content"u8.ToArray()) + }); + } + + return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.NotFound)); + } + } } diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs b/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs index f4cd35ca6..da5208163 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs @@ -25,7 +25,7 @@ internal static class MarketplaceClient /// The deserialized manifest, or null on failure. public static async Task GetMarketplaceAsync(HttpClient http, string repo, string branch, CancellationToken ct = default) { - var url = $"{GitHubRawBase}/{repo}/{branch}/.github/plugin/marketplace.json"; + var url = BuildRawUrl(repo, branch, ".github/plugin/marketplace.json"); var json = await FetchStringAsync(http, url, ct).ConfigureAwait(false); if (json is null) return null; @@ -44,7 +44,7 @@ internal static class MarketplaceClient public static async Task GetPluginAsync(HttpClient http, string repo, string branch, string pluginSourcePath, CancellationToken ct = default) { var path = NormalizePath($"{pluginSourcePath}/plugin.json"); - var url = $"{GitHubRawBase}/{repo}/{branch}/{path}"; + var url = BuildRawUrl(repo, branch, path); var json = await FetchStringAsync(http, url, ct).ConfigureAwait(false); if (json is null) return null; @@ -75,6 +75,9 @@ internal static class MarketplaceClient return null; var treeNode = JsonNode.Parse(treeJson); + if (treeNode?["truncated"]?.GetValue() == true) + throw new InvalidOperationException($"GitHub tree for '{repo}@{branch}' is truncated; cannot safely discover all MAUI AI assets."); + var treeArray = treeNode?["tree"]?.AsArray(); if (treeArray is null) return null; @@ -170,21 +173,32 @@ public static async Task DownloadSkillFilesAsync( foreach (var filePath in skill.Files) { - // Compute the path relative to the skill's remote root. - var relativePath = filePath; - var remotePrefix = skill.RemotePath + "/"; - if (filePath.StartsWith(remotePrefix, StringComparison.Ordinal)) - relativePath = filePath[remotePrefix.Length..]; + string normalizedFilePath; + string remotePrefix; + try + { + normalizedFilePath = NormalizePath(filePath); + remotePrefix = NormalizePath(skill.RemotePath) + "/"; + } + catch (InvalidOperationException) + { + continue; + } - var content = await FetchRawBytesAsync(http, repo, branch, filePath, ct).ConfigureAwait(false); - if (content is null) + if (!normalizedFilePath.StartsWith(remotePrefix, StringComparison.Ordinal)) continue; + var relativePath = normalizedFilePath[remotePrefix.Length..]; + var destPath = Path.Combine(destDir, relativePath.Replace('/', Path.DirectorySeparatorChar)); // Validate the resolved path stays under the destination directory. var fullDest = Path.GetFullPath(destPath); - if (!fullDest.StartsWith(fullBase, StringComparison.OrdinalIgnoreCase)) + if (!fullDest.StartsWith(fullBase, PathComparison)) + continue; + + var content = await FetchRawBytesAsync(http, repo, branch, normalizedFilePath, ct).ConfigureAwait(false); + if (content is null) continue; var destFileDir = Path.GetDirectoryName(destPath); @@ -208,7 +222,8 @@ public static async Task DownloadSkillFilesAsync( /// The commit SHA, or null on failure. public static async Task GetRemoteCommitShaAsync(HttpClient http, string repo, string branch, string path, CancellationToken ct = default) { - var url = $"{GitHubApiBase}/repos/{repo}/commits?sha={Uri.EscapeDataString(branch)}&path={Uri.EscapeDataString(path)}&per_page=1"; + var normalizedPath = NormalizePath(path); + var url = $"{GitHubApiBase}/repos/{repo}/commits?sha={Uri.EscapeDataString(branch)}&path={Uri.EscapeDataString(normalizedPath)}&per_page=1"; var json = await FetchStringAsync(http, url, ct).ConfigureAwait(false); if (json is null) return null; @@ -223,8 +238,7 @@ public static async Task DownloadSkillFilesAsync( internal static async Task FetchRawStringAsync( HttpClient http, string repo, string branch, string path, CancellationToken ct = default) { - var normalizedPath = NormalizePath(path); - var url = $"{GitHubRawBase}/{repo}/{branch}/{normalizedPath}"; + var url = BuildRawUrl(repo, branch, path); return await FetchStringAsync(http, url, ct).ConfigureAwait(false); } @@ -234,8 +248,7 @@ public static async Task DownloadSkillFilesAsync( internal static async Task FetchRawBytesAsync( HttpClient http, string repo, string branch, string path, CancellationToken ct = default) { - var normalizedPath = NormalizePath(path); - var url = $"{GitHubRawBase}/{repo}/{branch}/{normalizedPath}"; + var url = BuildRawUrl(repo, branch, path); return await FetchBytesAsync(http, url, ct).ConfigureAwait(false); } @@ -404,9 +417,27 @@ internal static string NormalizePath(string path) normalized = normalized[2..]; while (normalized.Contains("//")) normalized = normalized.Replace("//", "/"); - return normalized.TrimEnd('/'); + normalized = normalized.TrimEnd('/'); + + if (normalized.StartsWith("/", StringComparison.Ordinal) || + normalized.Split('/', StringSplitOptions.RemoveEmptyEntries).Any(segment => segment == "..")) + { + throw new InvalidOperationException($"Repository path '{path}' must be relative and cannot contain '..' segments."); + } + + return normalized; } + static string BuildRawUrl(string repo, string branch, string path) + { + var normalizedPath = NormalizePath(path); + var encodedPath = string.Join("/", normalizedPath.Split('/').Select(Uri.EscapeDataString)); + return $"{GitHubRawBase}/{repo}/{Uri.EscapeDataString(branch)}/{encodedPath}"; + } + + static StringComparison PathComparison => + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + private static async Task FetchStringAsync(HttpClient http, string url, CancellationToken ct = default) { try diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs b/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs index 06b7321b4..fca486271 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs @@ -14,6 +14,12 @@ namespace Microsoft.Maui.Cli.Ai; internal static class McpConfigurator { private const string ServerName = "maui-devflow"; + enum ConfigureResult + { + AlreadyConfigured, + Updated, + IncompatibleSchema + } /// /// Ensures the maui-devflow MCP server entry exists in the @@ -33,7 +39,10 @@ public static async Task ConfigureAsync(DetectedEnvironment env, Cancellat if (File.Exists(configPath)) { var existingJson = await File.ReadAllTextAsync(configPath, ct).ConfigureAwait(false); - root = JsonNode.Parse(existingJson) as JsonObject ?? new JsonObject(); + if (JsonNode.Parse(existingJson) is not JsonObject existingRoot) + return false; + + root = existingRoot; } else { @@ -46,26 +55,22 @@ public static async Task ConfigureAsync(DetectedEnvironment env, Cancellat ["args"] = new JsonArray("devflow", "mcp") }; - bool alreadyConfigured; - if (env.Kind == AgentEnvironmentKind.OpenCode) - { - alreadyConfigured = EnsureOpenCodeEntry(root, serverEntry); - } - else - { - alreadyConfigured = EnsureStandardEntry(root, serverEntry); - } + var configureResult = env.Kind == AgentEnvironmentKind.OpenCode + ? EnsureOpenCodeEntry(root, serverEntry) + : EnsureStandardEntry(root, serverEntry); - if (alreadyConfigured) + if (configureResult == ConfigureResult.AlreadyConfigured) return true; + if (configureResult == ConfigureResult.IncompatibleSchema) + return false; // Ensure the config directory exists before writing. var configDir = Path.GetDirectoryName(configPath); - if (configDir is not null) + if (!string.IsNullOrEmpty(configDir)) Directory.CreateDirectory(configDir); var options = new JsonSerializerOptions { WriteIndented = true }; - await File.WriteAllTextAsync(configPath, root.ToJsonString(options), ct).ConfigureAwait(false); + await WriteAtomicAsync(configPath, root.ToJsonString(options), ct).ConfigureAwait(false); return true; } @@ -87,12 +92,11 @@ public static async Task ConfigureAsync(DetectedEnvironment env, Cancellat /// Adds the server entry under the standard mcpServers key used by /// Claude, VS Code, and Copilot CLI. /// - /// true if the entry already exists (no changes needed). - private static bool EnsureStandardEntry(JsonObject root, JsonObject serverEntry) + private static ConfigureResult EnsureStandardEntry(JsonObject root, JsonObject serverEntry) { var existing = root["mcpServers"]; if (existing is not null and not JsonObject) - return false; + return ConfigureResult.IncompatibleSchema; if (existing is not JsonObject mcpServers) { @@ -101,21 +105,20 @@ private static bool EnsureStandardEntry(JsonObject root, JsonObject serverEntry) } if (mcpServers[ServerName] is not null) - return true; + return ConfigureResult.AlreadyConfigured; mcpServers[ServerName] = serverEntry; - return false; + return ConfigureResult.Updated; } /// /// Adds the server entry under the OpenCode-specific mcp.servers key. /// - /// true if the entry already exists (no changes needed). - private static bool EnsureOpenCodeEntry(JsonObject root, JsonObject serverEntry) + private static ConfigureResult EnsureOpenCodeEntry(JsonObject root, JsonObject serverEntry) { var existingMcp = root["mcp"]; if (existingMcp is not null and not JsonObject) - return false; + return ConfigureResult.IncompatibleSchema; if (existingMcp is not JsonObject mcp) { @@ -125,7 +128,7 @@ private static bool EnsureOpenCodeEntry(JsonObject root, JsonObject serverEntry) var existingServers = mcp["servers"]; if (existingServers is not null and not JsonObject) - return false; + return ConfigureResult.IncompatibleSchema; if (existingServers is not JsonObject servers) { @@ -134,9 +137,27 @@ private static bool EnsureOpenCodeEntry(JsonObject root, JsonObject serverEntry) } if (servers[ServerName] is not null) - return true; + return ConfigureResult.AlreadyConfigured; servers[ServerName] = serverEntry; - return false; + return ConfigureResult.Updated; + } + + static async Task WriteAtomicAsync(string configPath, string contents, CancellationToken ct) + { + var configDir = Path.GetDirectoryName(configPath); + var tempDir = string.IsNullOrEmpty(configDir) ? Directory.GetCurrentDirectory() : configDir; + var tempPath = Path.Combine(tempDir, $".{Path.GetFileName(configPath)}.{Guid.NewGuid():N}.tmp"); + + try + { + await File.WriteAllTextAsync(tempPath, contents, ct).ConfigureAwait(false); + File.Move(tempPath, configPath, overwrite: true); + } + finally + { + if (File.Exists(tempPath)) + File.Delete(tempPath); + } } } diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs b/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs index 48c3f0fcb..1f91f86ce 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs @@ -57,15 +57,16 @@ internal static class SkillInstaller return (0, installPath); } - Directory.CreateDirectory(installPath); + Directory.CreateDirectory(skillsDir); + var tempInstallPath = Path.Combine(skillsDir, $".{skill.Name}.{Guid.NewGuid():N}.tmp"); + Directory.CreateDirectory(tempInstallPath); var filesInstalled = await MarketplaceClient.DownloadSkillFilesAsync( - http, skill, installPath, repo, branch, ct).ConfigureAwait(false); + http, skill, tempInstallPath, repo, branch, ct).ConfigureAwait(false); - if (filesInstalled == 0) + if (skill.Files.Count == 0 || filesInstalled != skill.Files.Count) { - // Clean up empty directory - try { if (Directory.Exists(installPath) && !Directory.EnumerateFileSystemEntries(installPath).Any()) Directory.Delete(installPath); } catch { } + DeleteDirectoryIfExists(tempInstallPath); return (-2, installPath); } @@ -83,8 +84,48 @@ internal static class SkillInstaller PluginPath = skill.RemotePath }; - await SkillVersionStore.WriteAsync(installPath, version, ct).ConfigureAwait(false); + await SkillVersionStore.WriteAsync(tempInstallPath, version, ct).ConfigureAwait(false); + + CopyDirectory(tempInstallPath, installPath); + DeleteDirectoryIfExists(tempInstallPath); return (filesInstalled, installPath); } + + static void CopyDirectory(string sourceDirectory, string destinationDirectory) + { + Directory.CreateDirectory(destinationDirectory); + foreach (var directoryPath in Directory.EnumerateDirectories(sourceDirectory, "*", SearchOption.AllDirectories)) + { + var relativePath = Path.GetRelativePath(sourceDirectory, directoryPath); + Directory.CreateDirectory(Path.Combine(destinationDirectory, relativePath)); + } + + foreach (var filePath in Directory.EnumerateFiles(sourceDirectory, "*", SearchOption.AllDirectories)) + { + var relativePath = Path.GetRelativePath(sourceDirectory, filePath); + var destinationPath = Path.Combine(destinationDirectory, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); + File.Copy(filePath, destinationPath, overwrite: true); + } + } + + static void DeleteDirectoryIfExists(string path) + { + if (!Directory.Exists(path)) + return; + + try + { + Directory.Delete(path, recursive: true); + } + catch (IOException ex) + { + throw new InvalidOperationException($"Could not clean up temporary skill installation directory '{path}'.", ex); + } + catch (UnauthorizedAccessException ex) + { + throw new InvalidOperationException($"Could not clean up temporary skill installation directory '{path}'.", ex); + } + } } diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/SkillVersionStore.cs b/src/Cli/Microsoft.Maui.Cli/Ai/SkillVersionStore.cs index 634a33f87..46ca4e026 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/SkillVersionStore.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/SkillVersionStore.cs @@ -14,6 +14,10 @@ namespace Microsoft.Maui.Cli.Ai; internal static class SkillVersionStore { private const string VersionFileName = ".skill-version"; + private static readonly AiJsonContext s_indentedJsonContext = new(new JsonSerializerOptions(AiJsonContext.Default.Options) + { + WriteIndented = true + }); /// /// Reads the .skill-version file from the specified skill directory. @@ -54,12 +58,7 @@ public static async Task WriteAsync(string skillDir, InstalledSkillVersion versi Directory.CreateDirectory(skillDir); var path = Path.Combine(skillDir, VersionFileName); - var json = JsonSerializer.Serialize(version, AiJsonContext.Default.InstalledSkillVersion); - - // Re-format as indented JSON for human readability. - var node = JsonNode.Parse(json); - var indented = node?.ToJsonString(new JsonSerializerOptions { WriteIndented = true }) ?? json; - - await File.WriteAllTextAsync(path, indented, ct).ConfigureAwait(false); + var json = JsonSerializer.Serialize(version, s_indentedJsonContext.InstalledSkillVersion); + await File.WriteAllTextAsync(path, json, ct).ConfigureAwait(false); } } From d6144c3866dc4b455485902b9acc9ed249f0bf51 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Thu, 21 May 2026 13:31:36 +0200 Subject: [PATCH 13/31] Address follow-up MAUI AI review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MarketplaceClientTests.cs | 50 +++++++++++++++++++ .../SkillInstallerTests.cs | 32 ++++++++++++ .../Ai/MarketplaceClient.cs | 39 +++++++++++++-- .../Microsoft.Maui.Cli/Ai/SkillInstaller.cs | 49 +++++++++--------- 4 files changed, 143 insertions(+), 27 deletions(-) diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs index b91bfaccc..580ad6737 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs @@ -418,6 +418,56 @@ await MarketplaceClient.FetchRawStringAsync( Assert.Contains("SKILL%23.md", url); } + [Theory] + [InlineData("")] + [InlineData("owner")] + [InlineData("owner/")] + [InlineData("/repo")] + [InlineData("owner/repo/extra")] + [InlineData("owner/repo?foo=bar")] + [InlineData("owner/re po")] + public void EncodeRepoPath_InvalidRepo_Throws(string repo) + { + var exception = Assert.Throws(() => MarketplaceClient.EncodeRepoPath(repo)); + Assert.Contains("owner/repo", exception.Message); + } + + [Fact] + public async Task FetchRawStringAsync_InvalidRepo_ThrowsBeforeHttpCall() + { + var handler = new FakeHttpMessageHandler(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("content") + }); + using var http = new HttpClient(handler); + + await Assert.ThrowsAsync(() => MarketplaceClient.FetchRawStringAsync( + http, + "owner/repo?foo=bar", + "main", + "README.md")); + + Assert.Equal(0, handler.Calls); + } + + [Fact] + public async Task GetRemoteCommitShaAsync_InvalidRepo_ThrowsBeforeHttpCall() + { + var handler = new FakeHttpMessageHandler(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("[]") + }); + using var http = new HttpClient(handler); + + await Assert.ThrowsAsync(() => MarketplaceClient.GetRemoteCommitShaAsync( + http, + "owner/repo/extra", + "main", + "README.md")); + + Assert.Equal(0, handler.Calls); + } + /// /// Minimal HttpMessageHandler that returns a fixed response for every request. /// diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs index f4e00e736..2b2c5b698 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs @@ -115,6 +115,29 @@ public async Task InstallSkillAsync_PartialDownload_RollsBackAndReturnsNegativeT Assert.Empty(Directory.EnumerateFileSystemEntries(skillsDir)); } + [Fact] + public async Task InstallSkillAsync_DownloadThrows_RemovesTempInstallDirectory() + { + var skill = new SkillInfo + { + Name = "throwing-skill", + RemotePath = ".github/skills/throwing-skill", + Files = [".github/skills/throwing-skill/SKILL.md"] + }; + var skillsDir = Path.Combine(_tempDir, "skills"); + var env = new DetectedEnvironment + { + Kind = AgentEnvironmentKind.Claude, + SkillsDirectory = skillsDir + }; + using var http = new HttpClient(new ThrowingHandler()); + + await Assert.ThrowsAsync(() => SkillInstaller.InstallSkillAsync( + http, skill, env, _tempDir, "owner/repo", "main", force: false)); + + Assert.Empty(Directory.EnumerateFileSystemEntries(skillsDir)); + } + private sealed class NotFoundHandler : HttpMessageHandler { protected override Task SendAsync( @@ -140,4 +163,13 @@ protected override Task SendAsync( return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.NotFound)); } } + + private sealed class ThrowingHandler : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + throw new InvalidOperationException("download failed"); + } + } } diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs b/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs index da5208163..41736539d 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs @@ -65,11 +65,12 @@ internal static class MarketplaceClient public static async Task?> FetchTreeEntriesAsync( HttpClient http, string repo, string branch, CancellationToken ct = default) { + var encodedRepo = EncodeRepoPath(repo); var treeSha = await ResolveTreeShaAsync(http, repo, branch, ct).ConfigureAwait(false); if (treeSha is null) return null; - var treeUrl = $"{GitHubApiBase}/repos/{repo}/git/trees/{treeSha}?recursive=1"; + var treeUrl = $"{GitHubApiBase}/repos/{encodedRepo}/git/trees/{Uri.EscapeDataString(treeSha)}?recursive=1"; var treeJson = await FetchStringAsync(http, treeUrl, ct).ConfigureAwait(false); if (treeJson is null) return null; @@ -222,8 +223,9 @@ public static async Task DownloadSkillFilesAsync( /// The commit SHA, or null on failure. public static async Task GetRemoteCommitShaAsync(HttpClient http, string repo, string branch, string path, CancellationToken ct = default) { + var encodedRepo = EncodeRepoPath(repo); var normalizedPath = NormalizePath(path); - var url = $"{GitHubApiBase}/repos/{repo}/commits?sha={Uri.EscapeDataString(branch)}&path={Uri.EscapeDataString(normalizedPath)}&per_page=1"; + var url = $"{GitHubApiBase}/repos/{encodedRepo}/commits?sha={Uri.EscapeDataString(branch)}&path={Uri.EscapeDataString(normalizedPath)}&per_page=1"; var json = await FetchStringAsync(http, url, ct).ConfigureAwait(false); if (json is null) return null; @@ -257,7 +259,8 @@ public static async Task DownloadSkillFilesAsync( /// private static async Task ResolveTreeShaAsync(HttpClient http, string repo, string branch, CancellationToken ct = default) { - var url = $"{GitHubApiBase}/repos/{repo}/commits/{Uri.EscapeDataString(branch)}"; + var encodedRepo = EncodeRepoPath(repo); + var url = $"{GitHubApiBase}/repos/{encodedRepo}/commits/{Uri.EscapeDataString(branch)}"; var json = await FetchStringAsync(http, url, ct).ConfigureAwait(false); if (json is null) return null; @@ -430,9 +433,37 @@ internal static string NormalizePath(string path) static string BuildRawUrl(string repo, string branch, string path) { + var encodedRepo = EncodeRepoPath(repo); var normalizedPath = NormalizePath(path); var encodedPath = string.Join("/", normalizedPath.Split('/').Select(Uri.EscapeDataString)); - return $"{GitHubRawBase}/{repo}/{Uri.EscapeDataString(branch)}/{encodedPath}"; + return $"{GitHubRawBase}/{encodedRepo}/{Uri.EscapeDataString(branch)}/{encodedPath}"; + } + + internal static string EncodeRepoPath(string repo) + { + var segments = repo.Split('/'); + if (segments.Length != 2 || + !IsValidRepoSegment(segments[0]) || + !IsValidRepoSegment(segments[1])) + { + throw new InvalidOperationException($"GitHub repository '{repo}' must use the format 'owner/repo' and contain only letters, numbers, '.', '_', or '-'."); + } + + return $"{Uri.EscapeDataString(segments[0])}/{Uri.EscapeDataString(segments[1])}"; + } + + static bool IsValidRepoSegment(string segment) + { + if (segment.Length == 0) + return false; + + foreach (var ch in segment) + { + if (!char.IsAsciiLetterOrDigit(ch) && ch is not '.' and not '_' and not '-') + return false; + } + + return true; } static StringComparison PathComparison => diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs b/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs index 1f91f86ce..936bbf3f1 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs @@ -61,35 +61,38 @@ internal static class SkillInstaller var tempInstallPath = Path.Combine(skillsDir, $".{skill.Name}.{Guid.NewGuid():N}.tmp"); Directory.CreateDirectory(tempInstallPath); - var filesInstalled = await MarketplaceClient.DownloadSkillFilesAsync( - http, skill, tempInstallPath, repo, branch, ct).ConfigureAwait(false); - - if (skill.Files.Count == 0 || filesInstalled != skill.Files.Count) + try { - DeleteDirectoryIfExists(tempInstallPath); - return (-2, installPath); - } + var filesInstalled = await MarketplaceClient.DownloadSkillFilesAsync( + http, skill, tempInstallPath, repo, branch, ct).ConfigureAwait(false); - // Resolve the latest commit SHA for version tracking. - var commitSha = await MarketplaceClient.GetRemoteCommitShaAsync( - http, repo, branch, skill.RemotePath, ct).ConfigureAwait(false); + if (skill.Files.Count == 0 || filesInstalled != skill.Files.Count) + return (-2, installPath); - var version = new InstalledSkillVersion - { - Name = skill.Name, - Commit = commitSha, - Branch = branch, - UpdatedAt = DateTime.UtcNow.ToString("o"), - Source = repo, - PluginPath = skill.RemotePath - }; + // Resolve the latest commit SHA for version tracking. + var commitSha = await MarketplaceClient.GetRemoteCommitShaAsync( + http, repo, branch, skill.RemotePath, ct).ConfigureAwait(false); - await SkillVersionStore.WriteAsync(tempInstallPath, version, ct).ConfigureAwait(false); + var version = new InstalledSkillVersion + { + Name = skill.Name, + Commit = commitSha, + Branch = branch, + UpdatedAt = DateTime.UtcNow.ToString("o"), + Source = repo, + PluginPath = skill.RemotePath + }; - CopyDirectory(tempInstallPath, installPath); - DeleteDirectoryIfExists(tempInstallPath); + await SkillVersionStore.WriteAsync(tempInstallPath, version, ct).ConfigureAwait(false); - return (filesInstalled, installPath); + CopyDirectory(tempInstallPath, installPath); + + return (filesInstalled, installPath); + } + finally + { + DeleteDirectoryIfExists(tempInstallPath); + } } static void CopyDirectory(string sourceDirectory, string destinationDirectory) From 97c66a9c1a560653ccbe9446210059304f4a7589 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Thu, 21 May 2026 18:32:37 +0200 Subject: [PATCH 14/31] Address additional MAUI AI review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AgentEnvironmentDetectorTests.cs | 12 +++++ .../AiCommandsTests.cs | 12 +++++ .../McpConfiguratorTests.cs | 34 ++++++++++++ .../SkillInstallerTests.cs | 52 +++++++++++++++++++ .../Ai/AgentEnvironmentDetector.cs | 12 +++-- .../Microsoft.Maui.Cli/Ai/McpConfigurator.cs | 26 +++++++++- .../Microsoft.Maui.Cli/Ai/SkillInstaller.cs | 34 ++++++------ .../Commands/AiCommands.Init.cs | 9 ++-- .../Commands/AiCommands.Status.cs | 5 +- .../Commands/AiCommands.Update.cs | 14 ++--- 10 files changed, 175 insertions(+), 35 deletions(-) diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/AgentEnvironmentDetectorTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/AgentEnvironmentDetectorTests.cs index d1b1a9674..36a10513d 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/AgentEnvironmentDetectorTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/AgentEnvironmentDetectorTests.cs @@ -198,4 +198,16 @@ public void Detect_StopsAtGitRootFileForWorktree() Assert.Equal(Path.Combine(_tempDir, ".github", "skills"), vscode.SkillsDirectory); } + + [Fact] + public void ResolveProjectRoot_ReturnsGitRootFromSubdirectory() + { + File.WriteAllText(Path.Combine(_tempDir, ".git"), "gitdir: ../repo/.git/worktrees/project"); + var subDir = Path.Combine(_tempDir, "src", "project"); + Directory.CreateDirectory(subDir); + + var projectRoot = AgentEnvironmentDetector.ResolveProjectRoot(subDir); + + Assert.Equal(_tempDir, projectRoot); + } } diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs index e0e1f7e1f..38418fbfd 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs @@ -250,6 +250,18 @@ public void NeedsUpdate_RecognizesActionableStatuses(string status, bool force, Assert.Equal(expected, AiCommands.NeedsUpdate(row, force)); } + [Fact] + public void FileSystemPathComparer_MatchesCurrentPlatformSemantics() + { + var paths = new HashSet(AiCommands.FileSystemPathComparer) + { + Path.Combine("repo", ".github", "skills", "Foo"), + Path.Combine("repo", ".github", "skills", "foo") + }; + + Assert.Equal(OperatingSystem.IsWindows() ? 1 : 2, paths.Count); + } + private static void AssertNoWhitespaceAliases(Command command) { foreach (var option in command.Options) diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs index 36cf1d519..540c93f7a 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs @@ -113,6 +113,40 @@ public async Task ConfigureAsync_MergesIntoExistingConfig() Assert.NotNull(servers["maui-devflow"]); } + [Fact] + public async Task ConfigureAsync_MergesIntoJsoncConfig() + { + var configDir = Path.Combine(_tempDir, ".vscode"); + Directory.CreateDirectory(configDir); + var configPath = Path.Combine(configDir, "mcp.json"); + await File.WriteAllTextAsync(configPath, """ + { + // Existing user MCP server. + "mcpServers": { + "other-server": { + "command": "other", + }, + }, + } + """); + + var env = new DetectedEnvironment + { + Kind = AgentEnvironmentKind.VsCode, + McpConfigPath = configPath, + SkillsDirectory = Path.Combine(_tempDir, ".github", "skills") + }; + + var result = await McpConfigurator.ConfigureAsync(env); + + Assert.True(result); + var json = JsonNode.Parse(await File.ReadAllTextAsync(configPath)); + var servers = json?["mcpServers"]?.AsObject(); + Assert.NotNull(servers); + Assert.NotNull(servers["other-server"]); + Assert.NotNull(servers["maui-devflow"]); + } + [Fact] public async Task ConfigureAsync_Idempotent_DoesNotDuplicateEntry() { diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs index 2b2c5b698..bf07d890c 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs @@ -138,6 +138,38 @@ await Assert.ThrowsAsync(() => SkillInstaller.Install Assert.Empty(Directory.EnumerateFileSystemEntries(skillsDir)); } + [Fact] + public async Task InstallSkillAsync_Force_ReplacesExistingDirectory() + { + var skill = new SkillInfo + { + Name = "replaced-skill", + RemotePath = ".github/skills/replaced-skill", + Files = [".github/skills/replaced-skill/SKILL.md"] + }; + var skillsDir = Path.Combine(_tempDir, "skills"); + var installPath = Path.Combine(skillsDir, skill.Name); + Directory.CreateDirectory(Path.Combine(installPath, "references")); + await File.WriteAllTextAsync(Path.Combine(installPath, "SKILL.md"), "old content"); + await File.WriteAllTextAsync(Path.Combine(installPath, "references", "old-guide.md"), "stale content"); + var env = new DetectedEnvironment + { + Kind = AgentEnvironmentKind.Claude, + SkillsDirectory = skillsDir + }; + using var http = new HttpClient(new SuccessfulInstallHandler()); + + var (filesInstalled, resultPath) = await SkillInstaller.InstallSkillAsync( + http, skill, env, _tempDir, "owner/repo", "main", force: true); + + Assert.Equal(1, filesInstalled); + Assert.Equal(installPath, resultPath); + Assert.Equal("new content", await File.ReadAllTextAsync(Path.Combine(installPath, "SKILL.md"))); + Assert.True(File.Exists(Path.Combine(installPath, ".skill-version"))); + Assert.False(File.Exists(Path.Combine(installPath, "references", "old-guide.md"))); + Assert.DoesNotContain(Directory.EnumerateDirectories(skillsDir), path => Path.GetFileName(path).StartsWith($".{skill.Name}.", StringComparison.Ordinal)); + } + private sealed class NotFoundHandler : HttpMessageHandler { protected override Task SendAsync( @@ -172,4 +204,24 @@ protected override Task SendAsync( throw new InvalidOperationException("download failed"); } } + + private sealed class SuccessfulInstallHandler : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.RequestUri?.Host == "api.github.com") + { + return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new StringContent("""[{ "sha": "abc123" }]""") + }); + } + + return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new ByteArrayContent("new content"u8.ToArray()) + }); + } + } } diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/AgentEnvironmentDetector.cs b/src/Cli/Microsoft.Maui.Cli/Ai/AgentEnvironmentDetector.cs index 319acdf85..be47af003 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/AgentEnvironmentDetector.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/AgentEnvironmentDetector.cs @@ -22,7 +22,7 @@ public static List Detect(string workingDir) { var environments = new List(); var gitRoot = FindGitRoot(workingDir); - var searchRoot = gitRoot ?? workingDir; + var searchRoot = gitRoot ?? Path.GetFullPath(workingDir); var foundKinds = new HashSet(); var current = new DirectoryInfo(workingDir); @@ -73,7 +73,7 @@ public static List Detect(string workingDir) // Stop at the Git root. if (rootFullPath is not null && - string.Equals(current.FullName, rootFullPath, StringComparison.OrdinalIgnoreCase)) + string.Equals(current.FullName, rootFullPath, PathComparison)) break; current = current.Parent; @@ -97,10 +97,13 @@ public static List Detect(string workingDir) return environments; } + internal static string ResolveProjectRoot(string workingDir) + => FindGitRoot(workingDir) ?? Path.GetFullPath(workingDir); + /// /// Walks up from looking for a .git directory. /// - private static string? FindGitRoot(string startDir) + internal static string? FindGitRoot(string startDir) { var current = new DirectoryInfo(startDir); while (current is not null) @@ -119,4 +122,7 @@ private static bool IsGitRoot(string directory) var gitPath = Path.Combine(directory, ".git"); return Directory.Exists(gitPath) || File.Exists(gitPath); } + + static StringComparison PathComparison => + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; } diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs b/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs index fca486271..fc45a2db6 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs @@ -14,6 +14,12 @@ namespace Microsoft.Maui.Cli.Ai; internal static class McpConfigurator { private const string ServerName = "maui-devflow"; + private static readonly JsonDocumentOptions s_jsonDocumentOptions = new() + { + AllowTrailingCommas = true, + CommentHandling = JsonCommentHandling.Skip + }; + enum ConfigureResult { AlreadyConfigured, @@ -39,7 +45,7 @@ public static async Task ConfigureAsync(DetectedEnvironment env, Cancellat if (File.Exists(configPath)) { var existingJson = await File.ReadAllTextAsync(configPath, ct).ConfigureAwait(false); - if (JsonNode.Parse(existingJson) is not JsonObject existingRoot) + if (JsonNode.Parse(existingJson, documentOptions: s_jsonDocumentOptions) is not JsonObject existingRoot) return false; root = existingRoot; @@ -157,7 +163,23 @@ static async Task WriteAtomicAsync(string configPath, string contents, Cancellat finally { if (File.Exists(tempPath)) - File.Delete(tempPath); + TryDeleteFile(tempPath); + } + } + + static void TryDeleteFile(string path) + { + try + { + File.Delete(path); + } + catch (IOException) + { + // Best-effort temp cleanup should not hide the config write result. + } + catch (UnauthorizedAccessException) + { + // Best-effort temp cleanup should not hide the config write result. } } } diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs b/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs index 936bbf3f1..20a814003 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs @@ -85,32 +85,20 @@ internal static class SkillInstaller await SkillVersionStore.WriteAsync(tempInstallPath, version, ct).ConfigureAwait(false); - CopyDirectory(tempInstallPath, installPath); + ReplaceDirectory(tempInstallPath, installPath); return (filesInstalled, installPath); } finally { - DeleteDirectoryIfExists(tempInstallPath); + TryDeleteDirectoryIfExists(tempInstallPath); } } - static void CopyDirectory(string sourceDirectory, string destinationDirectory) + static void ReplaceDirectory(string sourceDirectory, string destinationDirectory) { - Directory.CreateDirectory(destinationDirectory); - foreach (var directoryPath in Directory.EnumerateDirectories(sourceDirectory, "*", SearchOption.AllDirectories)) - { - var relativePath = Path.GetRelativePath(sourceDirectory, directoryPath); - Directory.CreateDirectory(Path.Combine(destinationDirectory, relativePath)); - } - - foreach (var filePath in Directory.EnumerateFiles(sourceDirectory, "*", SearchOption.AllDirectories)) - { - var relativePath = Path.GetRelativePath(sourceDirectory, filePath); - var destinationPath = Path.Combine(destinationDirectory, relativePath); - Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); - File.Copy(filePath, destinationPath, overwrite: true); - } + DeleteDirectoryIfExists(destinationDirectory); + Directory.Move(sourceDirectory, destinationDirectory); } static void DeleteDirectoryIfExists(string path) @@ -131,4 +119,16 @@ static void DeleteDirectoryIfExists(string path) throw new InvalidOperationException($"Could not clean up temporary skill installation directory '{path}'.", ex); } } + + static void TryDeleteDirectoryIfExists(string path) + { + try + { + DeleteDirectoryIfExists(path); + } + catch (InvalidOperationException) + { + // Best-effort cleanup: do not hide the actual install result or root exception. + } + } } diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs index 0bac895ac..d51671b00 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs @@ -72,8 +72,9 @@ static Command CreateInitCommand() } // Step 2: Detect agent environments - var workingDir = Directory.GetCurrentDirectory(); - var environments = AgentEnvironmentDetector.Detect(workingDir); + var currentDir = Directory.GetCurrentDirectory(); + var workingDir = AgentEnvironmentDetector.ResolveProjectRoot(currentDir); + var environments = AgentEnvironmentDetector.Detect(currentDir); // Filter environments if --env specified if (envFilter is { Length: > 0 }) @@ -97,7 +98,7 @@ static Command CreateInitCommand() // In CI or force mode, create .claude/ by default var claudeDir = Path.Combine(workingDir, ".claude"); Directory.CreateDirectory(claudeDir); - environments = AgentEnvironmentDetector.Detect(workingDir); + environments = AgentEnvironmentDetector.Detect(currentDir); } else { @@ -113,7 +114,7 @@ static Command CreateInitCommand() var claudeDir = Path.Combine(workingDir, ".claude"); Directory.CreateDirectory(claudeDir); - environments = AgentEnvironmentDetector.Detect(workingDir); + environments = AgentEnvironmentDetector.Detect(currentDir); } } diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Status.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Status.cs index 7091f6cad..c492d1377 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Status.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Status.cs @@ -32,8 +32,9 @@ static Command CreateStatusCommand() try { - var workingDir = Directory.GetCurrentDirectory(); - var environments = AgentEnvironmentDetector.Detect(workingDir); + var currentDir = Directory.GetCurrentDirectory(); + var workingDir = AgentEnvironmentDetector.ResolveProjectRoot(currentDir); + var environments = AgentEnvironmentDetector.Detect(currentDir); if (environments.Count == 0) { diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs index 3e7c4d3b3..026c3c029 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs @@ -46,8 +46,9 @@ static Command CreateUpdateCommand() try { - var workingDir = Directory.GetCurrentDirectory(); - var environments = AgentEnvironmentDetector.Detect(workingDir); + var currentDir = Directory.GetCurrentDirectory(); + var workingDir = AgentEnvironmentDetector.ResolveProjectRoot(currentDir); + var environments = AgentEnvironmentDetector.Detect(currentDir); if (environments.Count == 0) { @@ -96,7 +97,7 @@ static Command CreateUpdateCommand() // Scan installed marketplace/repository skills and check for updates; de-duplicate by resolved path // so environments sharing the same skills directory are not updated twice. var updatable = new List<(DetectedEnvironment Env, string SkillDir, string SkillName, InstalledSkillVersion Version)>(); - var processedPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + var processedPaths = new HashSet(FileSystemPathComparer); var uncheckableCount = 0; foreach (var env in environments) @@ -154,8 +155,6 @@ version.Commit is null || { var skillWord = uncheckableCount == 1 ? "skill" : "skill(s)"; formatter.WriteWarning($"Could not check {uncheckableCount} {skillWord} — GitHub may be unreachable."); - if (isCi) - return 1; } return 0; @@ -306,8 +305,6 @@ version.Commit is null || { var skillWord = uncheckableCount == 1 ? "skill" : "skill(s)"; formatter.WriteWarning($"⚠ Could not check {uncheckableCount} {skillWord} — GitHub may be unreachable."); - if (isCi) - return 1; } return 0; @@ -330,4 +327,7 @@ static string ShortCommit(string? commit) => string.IsNullOrEmpty(commit) ? "unknown" : commit[..Math.Min(commit.Length, 7)]; + + internal static StringComparer FileSystemPathComparer => + OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; } From 44d193abdfa9d83afe7e23bae243f1bebc3646e9 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Thu, 21 May 2026 18:56:39 +0200 Subject: [PATCH 15/31] Harden MAUI AI bootstrap review fixes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AgentEnvironmentDetectorTests.cs | 12 ++++++ .../AiCommandsTests.cs | 17 ++++++++ .../MarketplaceClientTests.cs | 40 +++++++++++++++++++ .../SkillInstallerTests.cs | 1 + .../Ai/AgentEnvironmentDetector.cs | 3 ++ .../Ai/MarketplaceClient.cs | 30 +++++++++++++- .../Microsoft.Maui.Cli/Ai/SkillInstaller.cs | 37 ++++++++++++++++- .../Commands/AiCommands.Init.cs | 22 ++++++---- 8 files changed, 150 insertions(+), 12 deletions(-) diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/AgentEnvironmentDetectorTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/AgentEnvironmentDetectorTests.cs index 36a10513d..88d67bc5e 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/AgentEnvironmentDetectorTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/AgentEnvironmentDetectorTests.cs @@ -210,4 +210,16 @@ public void ResolveProjectRoot_ReturnsGitRootFromSubdirectory() Assert.Equal(_tempDir, projectRoot); } + + [Fact] + public void Detect_NoGitRoot_DoesNotScanAncestorDirectories() + { + Directory.CreateDirectory(Path.Combine(_tempDir, ".vscode")); + var subDir = Path.Combine(_tempDir, "src", "project"); + Directory.CreateDirectory(subDir); + + var environments = AgentEnvironmentDetector.Detect(subDir); + + Assert.DoesNotContain(environments, e => e.Kind == AgentEnvironmentKind.VsCode); + } } diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs index 38418fbfd..3110ffa24 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs @@ -262,6 +262,23 @@ public void FileSystemPathComparer_MatchesCurrentPlatformSemantics() Assert.Equal(OperatingSystem.IsWindows() ? 1 : 2, paths.Count); } + [Theory] + [InlineData(false, "success")] + [InlineData(true, "partial")] + public void GetInitStatus_ReflectsInstallFailures(bool hasInstallFailures, string expected) + { + Assert.Equal(expected, AiCommands.GetInitStatus(hasInstallFailures)); + } + + [Theory] + [InlineData(new[] { 1, 2 }, new[] { 0 }, false)] + [InlineData(new[] { -2, 1 }, new[] { 0 }, true)] + [InlineData(new[] { 1 }, new[] { -1 }, true)] + public void HasInitInstallFailures_DetectsNegativeFileCounts(int[] skillFileCounts, int[] assetFileCounts, bool expected) + { + Assert.Equal(expected, AiCommands.HasInitInstallFailures(skillFileCounts, assetFileCounts)); + } + private static void AssertNoWhitespaceAliases(Command command) { foreach (var option in command.Options) diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs index 580ad6737..66d9b8b73 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs @@ -468,6 +468,34 @@ await Assert.ThrowsAsync(() => MarketplaceClient.GetR Assert.Equal(0, handler.Calls); } + [Fact] + public async Task FetchRawStringAsync_OversizedResponse_ReturnsNull() + { + var handler = new FakeHttpMessageHandler(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new OversizedHttpContent() + }); + using var http = new HttpClient(handler); + + var result = await MarketplaceClient.FetchRawStringAsync(http, "owner/repo", "main", "README.md"); + + Assert.Null(result); + } + + [Fact] + public async Task FetchRawBytesAsync_OversizedResponse_ReturnsNull() + { + var handler = new FakeHttpMessageHandler(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new OversizedHttpContent() + }); + using var http = new HttpClient(handler); + + var result = await MarketplaceClient.FetchRawBytesAsync(http, "owner/repo", "main", "README.md"); + + Assert.Null(result); + } + /// /// Minimal HttpMessageHandler that returns a fixed response for every request. /// @@ -506,4 +534,16 @@ protected override Task SendAsync(HttpRequestMessage reques return Task.FromResult(handler(request)); } } + + private sealed class OversizedHttpContent : HttpContent + { + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) + => Task.CompletedTask; + + protected override bool TryComputeLength(out long length) + { + length = 11L * 1024 * 1024; + return true; + } + } } diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs index bf07d890c..157132936 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs @@ -167,6 +167,7 @@ public async Task InstallSkillAsync_Force_ReplacesExistingDirectory() Assert.Equal("new content", await File.ReadAllTextAsync(Path.Combine(installPath, "SKILL.md"))); Assert.True(File.Exists(Path.Combine(installPath, ".skill-version"))); Assert.False(File.Exists(Path.Combine(installPath, "references", "old-guide.md"))); + Assert.Empty(Directory.EnumerateDirectories(skillsDir, "*.bak")); Assert.DoesNotContain(Directory.EnumerateDirectories(skillsDir), path => Path.GetFileName(path).StartsWith($".{skill.Name}.", StringComparison.Ordinal)); } diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/AgentEnvironmentDetector.cs b/src/Cli/Microsoft.Maui.Cli/Ai/AgentEnvironmentDetector.cs index be47af003..b97edd695 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/AgentEnvironmentDetector.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/AgentEnvironmentDetector.cs @@ -72,6 +72,9 @@ public static List Detect(string workingDir) } // Stop at the Git root. + if (rootFullPath is null) + break; + if (rootFullPath is not null && string.Equals(current.FullName, rootFullPath, PathComparison)) break; diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs b/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs index 41736539d..5525f9cf5 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs @@ -3,6 +3,7 @@ using System.Text.Json; using System.Text.Json.Nodes; +using System.Text; using Microsoft.Maui.Cli.Ai.Models; namespace Microsoft.Maui.Cli.Ai; @@ -15,6 +16,7 @@ internal static class MarketplaceClient { private const string GitHubApiBase = "https://api.github.com"; private const string GitHubRawBase = "https://raw.githubusercontent.com"; + private const int MaxResponseBytes = 10 * 1024 * 1024; /// /// Fetches and deserializes the marketplace.json manifest from the repository. @@ -481,7 +483,8 @@ static bool IsValidRepoSegment(string segment) response.EnsureSuccessStatusCode(); - return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + var bytes = await ReadBytesWithLimitAsync(response.Content, ct).ConfigureAwait(false); + return bytes is null ? null : Encoding.UTF8.GetString(bytes); } catch (TaskCanceledException) when (!ct.IsCancellationRequested) { @@ -501,11 +504,34 @@ static bool IsValidRepoSegment(string segment) response.EnsureSuccessStatusCode(); - return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false); + return await ReadBytesWithLimitAsync(response.Content, ct).ConfigureAwait(false); } catch (TaskCanceledException) when (!ct.IsCancellationRequested) { return null; // real HTTP timeout } } + + static async Task ReadBytesWithLimitAsync(HttpContent content, CancellationToken ct) + { + if (content.Headers.ContentLength > MaxResponseBytes) + return null; + + await using var stream = await content.ReadAsStreamAsync(ct).ConfigureAwait(false); + using var buffer = new MemoryStream(); + var readBuffer = new byte[81920]; + while (true) + { + var read = await stream.ReadAsync(readBuffer, ct).ConfigureAwait(false); + if (read == 0) + break; + + if (buffer.Length + read > MaxResponseBytes) + return null; + + buffer.Write(readBuffer, 0, read); + } + + return buffer.ToArray(); + } } diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs b/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs index 20a814003..81a36bf66 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Runtime.ExceptionServices; using Microsoft.Maui.Cli.Ai.Models; namespace Microsoft.Maui.Cli.Ai; @@ -97,8 +98,40 @@ internal static class SkillInstaller static void ReplaceDirectory(string sourceDirectory, string destinationDirectory) { - DeleteDirectoryIfExists(destinationDirectory); - Directory.Move(sourceDirectory, destinationDirectory); + var backupDirectory = $"{destinationDirectory}.{Guid.NewGuid():N}.bak"; + TryDeleteDirectoryIfExists(backupDirectory); + + if (Directory.Exists(destinationDirectory)) + Directory.Move(destinationDirectory, backupDirectory); + + try + { + Directory.Move(sourceDirectory, destinationDirectory); + TryDeleteDirectoryIfExists(backupDirectory); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + RestoreBackupDirectory(backupDirectory, destinationDirectory, ex); + ExceptionDispatchInfo.Capture(ex).Throw(); + throw; + } + } + + static void RestoreBackupDirectory(string backupDirectory, string destinationDirectory, Exception originalException) + { + if (!Directory.Exists(backupDirectory) || Directory.Exists(destinationDirectory)) + return; + + try + { + Directory.Move(backupDirectory, destinationDirectory); + } + catch (Exception restoreException) when (restoreException is IOException or UnauthorizedAccessException) + { + throw new InvalidOperationException( + $"Could not replace skill directory '{destinationDirectory}' and could not restore the previous installation.", + new AggregateException(originalException, restoreException)); + } } static void DeleteDirectoryIfExists(string path) diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs index d51671b00..402312a41 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs @@ -87,12 +87,6 @@ static Command CreateInitCommand() if (environments.Count == 0) { - if (useJson) - { - formatter.WriteWarning("No agent environments detected."); - return 1; - } - if (isCi || force) { // In CI or force mode, create .claude/ by default @@ -100,6 +94,11 @@ static Command CreateInitCommand() Directory.CreateDirectory(claudeDir); environments = AgentEnvironmentDetector.Detect(currentDir); } + else if (useJson) + { + formatter.WriteWarning("No agent environments detected."); + return 1; + } else { // Prompt user to create a default environment @@ -292,6 +291,7 @@ static Command CreateInitCommand() summaryRows.AddRange(devFlowResults.Select(r => (r.Skill, "DevFlow", r.Target, r.Action, r.Path))); summaryRows.AddRange(skillResults.Select(r => (r.Skill, "Skill", r.Env, FormatFileResult(r.Files), r.Path))); summaryRows.AddRange(assetResults.Select(r => (r.Asset, r.Type, "GitHub Copilot", FormatFileResult(r.Files), r.Path))); + var hasInstallFailures = HasInitInstallFailures(skillResults.Select(r => r.Files), assetResults.Select(r => r.Files)); formatter.WriteTable( summaryRows, @@ -305,7 +305,7 @@ static Command CreateInitCommand() { var jsonResult = new JsonObject { - ["status"] = "success", + ["status"] = GetInitStatus(hasInstallFailures), ["devFlowSkills"] = new JsonArray(devFlowResults.Select(r => (JsonNode)new JsonObject { ["skill"] = r.Skill, @@ -340,7 +340,7 @@ static Command CreateInitCommand() AnsiConsole.MarkupLine("[dim] Run [green]maui devflow skills check[/] to check bundled DevFlow skills.[/]"); } - return 0; + return hasInstallFailures ? 1 : 0; } catch (HttpRequestException ex) { @@ -476,6 +476,12 @@ static string FormatFileResult(int files) => _ => files.ToString() }; + internal static bool HasInitInstallFailures(IEnumerable skillFileCounts, IEnumerable assetFileCounts) + => skillFileCounts.Any(files => files < 0) || assetFileCounts.Any(files => files < 0); + + internal static string GetInitStatus(bool hasInstallFailures) + => hasInstallFailures ? "partial" : "success"; + /// /// Fetches all skills from every plugin listed in the marketplace manifest, /// plus project-scoped GitHub Copilot skills that live in this repository. From 124ffb64adf5e11eec46f71beefe3883fbb3c7a6 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Thu, 21 May 2026 19:19:40 +0200 Subject: [PATCH 16/31] Address MAUI AI expert review findings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AiCommandsTests.cs | 42 +++++++++++++ .../MarketplaceClientTests.cs | 3 +- .../McpConfiguratorTests.cs | 3 + .../RepositoryAssetInstallerTests.cs | 45 ++++++++++++++ .../SkillInstallerTests.cs | 45 ++++++++++++++ .../Ai/FileSystemPathGuard.cs | 61 +++++++++++++++++++ .../Ai/MarketplaceClient.cs | 3 +- .../Microsoft.Maui.Cli/Ai/McpConfigurator.cs | 47 +++++++++++++- .../Ai/RepositoryAssetInstaller.cs | 3 + .../Microsoft.Maui.Cli/Ai/SkillInstaller.cs | 5 +- .../Commands/AiCommands.Init.cs | 39 ++++++++---- 11 files changed, 279 insertions(+), 17 deletions(-) create mode 100644 src/Cli/Microsoft.Maui.Cli/Ai/FileSystemPathGuard.cs diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs index 3110ffa24..4b850518e 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs @@ -250,6 +250,48 @@ public void NeedsUpdate_RecognizesActionableStatuses(string status, bool force, Assert.Equal(expected, AiCommands.NeedsUpdate(row, force)); } + [Fact] + public void FilterEnvironments_NoFilter_ReturnsAllEnvironments() + { + var environments = new[] + { + new DetectedEnvironment { Kind = AgentEnvironmentKind.Claude }, + new DetectedEnvironment { Kind = AgentEnvironmentKind.VsCode } + }; + + Assert.Equal(2, AiCommands.FilterEnvironments(environments, envFilter: null).Count); + } + + [Fact] + public void FilterEnvironments_EnvFilter_ReturnsMatchingEnvironment() + { + var environments = new[] + { + new DetectedEnvironment { Kind = AgentEnvironmentKind.Claude }, + new DetectedEnvironment { Kind = AgentEnvironmentKind.VsCode } + }; + + var env = Assert.Single(AiCommands.FilterEnvironments(environments, ["VsCode"])); + + Assert.Equal(AgentEnvironmentKind.VsCode, env.Kind); + } + + [Fact] + public void ShouldCreateDefaultClaudeEnvironment_NoFilter_ReturnsTrue() + { + Assert.True(AiCommands.ShouldCreateDefaultClaudeEnvironment(envFilter: null)); + } + + [Theory] + [InlineData("Claude", true)] + [InlineData("claude", true)] + [InlineData("VsCode", false)] + [InlineData("CopilotCli", false)] + public void ShouldCreateDefaultClaudeEnvironment_RespectsEnvFilter(string envFilter, bool expected) + { + Assert.Equal(expected, AiCommands.ShouldCreateDefaultClaudeEnvironment([envFilter])); + } + [Fact] public void FileSystemPathComparer_MatchesCurrentPlatformSemantics() { diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs index 66d9b8b73..0e4b677e7 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs @@ -413,7 +413,8 @@ await MarketplaceClient.FetchRawStringAsync( Assert.NotNull(requestUri); var url = requestUri!.AbsoluteUri; - Assert.Contains("feature%2Ftest", url); + Assert.Contains("/owner/repo/feature/test/.github/", url); + Assert.DoesNotContain("feature%2Ftest", url); Assert.Contains("my%20skill", url); Assert.Contains("SKILL%23.md", url); } diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs index 540c93f7a..319c5f252 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs @@ -140,6 +140,9 @@ await File.WriteAllTextAsync(configPath, """ var result = await McpConfigurator.ConfigureAsync(env); Assert.True(result); + var backup = await File.ReadAllTextAsync(configPath + ".bak"); + Assert.Contains("// Existing user MCP server.", backup); + var json = JsonNode.Parse(await File.ReadAllTextAsync(configPath)); var servers = json?["mcpServers"]?.AsObject(); Assert.NotNull(servers); diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/RepositoryAssetInstallerTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/RepositoryAssetInstallerTests.cs index 163fb9a55..c92dcd952 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/RepositoryAssetInstallerTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/RepositoryAssetInstallerTests.cs @@ -86,6 +86,38 @@ public async Task InstallAssetAsync_WritesAgentAndSkipsExistingWithoutForce() Assert.Equal(Path.Combine(_tempDir, ".github", "agents"), first.InstallPath); } + [Fact] + public async Task InstallAssetAsync_SymlinkedDestinationRootOutsideProject_ReturnsNegativeOne() + { + var projectRoot = Path.Combine(_tempDir, "project"); + var outsideRoot = Path.Combine(_tempDir, "outside"); + Directory.CreateDirectory(projectRoot); + Directory.CreateDirectory(outsideRoot); + + if (!TryCreateDirectorySymlink(Path.Combine(projectRoot, ".github"), outsideRoot)) + return; + + using var http = new HttpClient(new MapHttpMessageHandler(new Dictionary + { + ["expert-reviewer.agent.md"] = "agent content" + })); + var asset = new RepositoryAssetInfo + { + Name = "expert-reviewer", + Category = "agent", + RemotePath = ".github/agents/expert-reviewer.agent.md", + DestinationRoot = ".github/agents", + Files = [".github/agents/expert-reviewer.agent.md"] + }; + + var result = await RepositoryAssetInstaller.InstallAssetAsync( + http, asset, projectRoot, "owner/repo", "main", force: true); + + Assert.Equal(-1, result.FilesInstalled); + Assert.Equal(string.Empty, result.InstallPath); + Assert.False(File.Exists(Path.Combine(outsideRoot, "agents", "expert-reviewer.agent.md"))); + } + [Fact] public void GetInstalledCopilotAgents_ReturnsOnlyMauiRelatedAgents() { @@ -159,4 +191,17 @@ protected override Task SendAsync(HttpRequestMessage reques return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); } } + + static bool TryCreateDirectorySymlink(string linkPath, string targetPath) + { + try + { + Directory.CreateSymbolicLink(linkPath, targetPath); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return false; + } + } } diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs index 157132936..d812592b6 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs @@ -58,6 +58,38 @@ public async Task InstallSkillAsync_InvalidName_PathSeparator_ReturnsNegativeOne Assert.Equal(string.Empty, installPath); } + [Fact] + public async Task InstallSkillAsync_SymlinkedSkillsDirectoryOutsideProject_ReturnsNegativeOne() + { + var projectRoot = Path.Combine(_tempDir, "project"); + var outsideRoot = Path.Combine(_tempDir, "outside"); + Directory.CreateDirectory(projectRoot); + Directory.CreateDirectory(outsideRoot); + + if (!TryCreateDirectorySymlink(Path.Combine(projectRoot, ".claude"), outsideRoot)) + return; + + var skill = new SkillInfo + { + Name = "safe-name", + RemotePath = ".github/skills/safe-name", + Files = [".github/skills/safe-name/SKILL.md"] + }; + var env = new DetectedEnvironment + { + Kind = AgentEnvironmentKind.Claude, + SkillsDirectory = Path.Combine(projectRoot, ".claude", "skills") + }; + using var http = new HttpClient(new SuccessfulInstallHandler()); + + var (filesInstalled, installPath) = await SkillInstaller.InstallSkillAsync( + http, skill, env, projectRoot, "owner/repo", "main", force: true); + + Assert.Equal(-1, filesInstalled); + Assert.Equal(string.Empty, installPath); + Assert.False(Directory.Exists(Path.Combine(outsideRoot, "skills"))); + } + [Fact] public async Task InstallSkillAsync_ValidName_DoesNotReturnNegativeOne() { @@ -225,4 +257,17 @@ protected override Task SendAsync( }); } } + + static bool TryCreateDirectorySymlink(string linkPath, string targetPath) + { + try + { + Directory.CreateSymbolicLink(linkPath, targetPath); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return false; + } + } } diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/FileSystemPathGuard.cs b/src/Cli/Microsoft.Maui.Cli/Ai/FileSystemPathGuard.cs new file mode 100644 index 000000000..9e775f21d --- /dev/null +++ b/src/Cli/Microsoft.Maui.Cli/Ai/FileSystemPathGuard.cs @@ -0,0 +1,61 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Maui.Cli.Ai; + +internal static class FileSystemPathGuard +{ + internal static readonly StringComparison PathComparison = + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + + internal static bool IsPathWithinRoot(string path, string root) + { + var canonicalPath = Path.TrimEndingDirectorySeparator(ResolveCanonicalPath(path)); + var canonicalRoot = Path.TrimEndingDirectorySeparator(ResolveCanonicalPath(root)); + + if (string.Equals(canonicalPath, canonicalRoot, PathComparison)) + return true; + + var rootWithSeparator = Path.EndsInDirectorySeparator(canonicalRoot) + ? canonicalRoot + : canonicalRoot + Path.DirectorySeparatorChar; + + return canonicalPath.StartsWith(rootWithSeparator, PathComparison); + } + + internal static string ResolveCanonicalPath(string path) + { + var fullPath = Path.GetFullPath(path); + var root = Path.GetPathRoot(fullPath); + if (string.IsNullOrEmpty(root)) + return fullPath; + + var current = root; + var relative = Path.GetRelativePath(root, fullPath); + if (relative == ".") + return ResolveExistingFileSystemEntry(current); + + foreach (var segment in relative.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries)) + { + current = ResolveExistingFileSystemEntry(Path.Combine(current, segment)); + } + + return Path.GetFullPath(current); + } + + static string ResolveExistingFileSystemEntry(string path) + { + FileSystemInfo? info = Directory.Exists(path) + ? new DirectoryInfo(path) + : File.Exists(path) + ? new FileInfo(path) + : null; + + if (info is null) + return path; + + return info.ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? info.FullName; + } +} diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs b/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs index 5525f9cf5..cf0228fd6 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs @@ -436,9 +436,10 @@ internal static string NormalizePath(string path) static string BuildRawUrl(string repo, string branch, string path) { var encodedRepo = EncodeRepoPath(repo); + var encodedBranch = string.Join("/", branch.Split('/').Select(Uri.EscapeDataString)); var normalizedPath = NormalizePath(path); var encodedPath = string.Join("/", normalizedPath.Split('/').Select(Uri.EscapeDataString)); - return $"{GitHubRawBase}/{encodedRepo}/{Uri.EscapeDataString(branch)}/{encodedPath}"; + return $"{GitHubRawBase}/{encodedRepo}/{encodedBranch}/{encodedPath}"; } internal static string EncodeRepoPath(string repo) diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs b/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs index fc45a2db6..b35c74505 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using Microsoft.Maui.Cli.Ai.Models; @@ -42,13 +43,16 @@ public static async Task ConfigureAsync(DetectedEnvironment env, Cancellat var configPath = env.McpConfigPath; JsonObject root; + string? existingJson = null; + var backupExistingConfig = false; if (File.Exists(configPath)) { - var existingJson = await File.ReadAllTextAsync(configPath, ct).ConfigureAwait(false); + existingJson = await File.ReadAllTextAsync(configPath, ct).ConfigureAwait(false); if (JsonNode.Parse(existingJson, documentOptions: s_jsonDocumentOptions) is not JsonObject existingRoot) return false; root = existingRoot; + backupExistingConfig = ContainsJsonComments(existingJson); } else { @@ -76,6 +80,9 @@ public static async Task ConfigureAsync(DetectedEnvironment env, Cancellat Directory.CreateDirectory(configDir); var options = new JsonSerializerOptions { WriteIndented = true }; + if (backupExistingConfig && existingJson is not null) + await WriteBackupAsync(configPath, existingJson, ct).ConfigureAwait(false); + await WriteAtomicAsync(configPath, root.ToJsonString(options), ct).ConfigureAwait(false); return true; @@ -167,6 +174,44 @@ static async Task WriteAtomicAsync(string configPath, string contents, Cancellat } } + static async Task WriteBackupAsync(string configPath, string contents, CancellationToken ct) + { + await File.WriteAllTextAsync(GetBackupPath(configPath), contents, ct).ConfigureAwait(false); + } + + static string GetBackupPath(string configPath) + { + var backupPath = configPath + ".bak"; + if (!File.Exists(backupPath)) + return backupPath; + + for (var i = 1; ; i++) + { + var candidate = $"{configPath}.{i}.bak"; + if (!File.Exists(candidate)) + return candidate; + } + } + + static bool ContainsJsonComments(string contents) + { + var reader = new Utf8JsonReader( + Encoding.UTF8.GetBytes(contents), + new JsonReaderOptions + { + AllowTrailingCommas = true, + CommentHandling = JsonCommentHandling.Allow + }); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.Comment) + return true; + } + + return false; + } + static void TryDeleteFile(string path) { try diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/RepositoryAssetInstaller.cs b/src/Cli/Microsoft.Maui.Cli/Ai/RepositoryAssetInstaller.cs index 1f1dd58e3..b7e672b74 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/RepositoryAssetInstaller.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/RepositoryAssetInstaller.cs @@ -74,6 +74,9 @@ public static async Task> GetCopilotAgentsAsync( CancellationToken ct = default) { var destinationRoot = GetDestinationRoot(projectRoot, asset.DestinationRoot); + if (!FileSystemPathGuard.IsPathWithinRoot(destinationRoot, projectRoot)) + return (-1, string.Empty); + var destinationBase = Path.GetFullPath(destinationRoot) + Path.DirectorySeparatorChar; Directory.CreateDirectory(destinationRoot); diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs b/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs index 81a36bf66..11079c13c 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs @@ -27,7 +27,7 @@ internal static class SkillInstaller /// A tuple of (filesInstalled, installPath) where filesInstalled is the number /// of files written and installPath is the absolute path to the skill directory. /// Returns (0, installPath) if the skill is already installed and is false. - /// Returns (-1, string.Empty) if the skill name contains invalid characters. + /// Returns (-1, string.Empty) if the skill name contains invalid characters or targets an unsafe path. /// Returns (-2, installPath) if the download produced zero files (network or remote failure). /// public static async Task<(int FilesInstalled, string InstallPath)> InstallSkillAsync( @@ -48,6 +48,9 @@ internal static class SkillInstaller ? env.SkillsDirectory : Path.GetFullPath(Path.Combine(projectRoot, env.SkillsDirectory)); + if (!FileSystemPathGuard.IsPathWithinRoot(skillsDir, projectRoot)) + return (-1, string.Empty); + var installPath = Path.Combine(skillsDir, skill.Name); // Skip if already installed and not forcing. diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs index 402312a41..e89888472 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs @@ -76,25 +76,19 @@ static Command CreateInitCommand() var workingDir = AgentEnvironmentDetector.ResolveProjectRoot(currentDir); var environments = AgentEnvironmentDetector.Detect(currentDir); - // Filter environments if --env specified - if (envFilter is { Length: > 0 }) - { - environments = environments - .Where(e => envFilter.Any(f => - string.Equals(f, e.Kind.ToString(), StringComparison.OrdinalIgnoreCase))) - .ToList(); - } + environments = FilterEnvironments(environments, envFilter); if (environments.Count == 0) { - if (isCi || force) + var canCreateDefaultClaudeEnvironment = ShouldCreateDefaultClaudeEnvironment(envFilter); + if ((isCi || force) && canCreateDefaultClaudeEnvironment) { // In CI or force mode, create .claude/ by default var claudeDir = Path.Combine(workingDir, ".claude"); Directory.CreateDirectory(claudeDir); - environments = AgentEnvironmentDetector.Detect(currentDir); + environments = FilterEnvironments(AgentEnvironmentDetector.Detect(currentDir), envFilter); } - else if (useJson) + else if (!canCreateDefaultClaudeEnvironment || useJson || isCi || force) { formatter.WriteWarning("No agent environments detected."); return 1; @@ -113,7 +107,7 @@ static Command CreateInitCommand() var claudeDir = Path.Combine(workingDir, ".claude"); Directory.CreateDirectory(claudeDir); - environments = AgentEnvironmentDetector.Detect(currentDir); + environments = FilterEnvironments(AgentEnvironmentDetector.Detect(currentDir), envFilter); } } @@ -243,7 +237,7 @@ static Command CreateInitCommand() if (filesInstalled == -1) { - formatter.WriteWarning($"Skill '{skill.Name}' has an invalid name and cannot be installed."); + formatter.WriteWarning($"Skill '{skill.Name}' has an invalid name or unsafe install path and cannot be installed."); } else if (filesInstalled == -2) { @@ -266,6 +260,8 @@ static Command CreateInitCommand() assetResults.Add((asset.Name, asset.Category, filesInstalled, installPath)); if (filesInstalled > 0) formatter.WriteSuccess($"Installed {asset.Name} → {asset.Category} ({filesInstalled} files)"); + else if (filesInstalled < 0) + formatter.WriteWarning($"Skipped {asset.Name} → {asset.Category} (unsafe install path)"); else formatter.WriteInfo($"Skipped {asset.Name} → {asset.Category} (already installed)"); } @@ -406,6 +402,23 @@ static string GetSkillPromptLabel(SkillInfo skill) => ? skill.Name : $"{skill.Name} - {skill.Description}"; + internal static List FilterEnvironments( + IEnumerable environments, + string[]? envFilter) + { + if (envFilter is not { Length: > 0 }) + return environments.ToList(); + + return environments + .Where(e => envFilter.Any(f => + string.Equals(f, e.Kind.ToString(), StringComparison.OrdinalIgnoreCase))) + .ToList(); + } + + internal static bool ShouldCreateDefaultClaudeEnvironment(string[]? envFilter) + => envFilter is not { Length: > 0 } || + envFilter.Any(f => string.Equals(f, AgentEnvironmentKind.Claude.ToString(), StringComparison.OrdinalIgnoreCase)); + internal static List GetDevFlowBootstrapTargets(IEnumerable environments) { var targets = new List(); From fe2e0f4f4653c76c96669a0c96f5115a74342091 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Thu, 21 May 2026 19:40:03 +0200 Subject: [PATCH 17/31] Fix MAUI AI review regressions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AiCommandsTests.cs | 39 +++++++++++++++++++ .../McpConfiguratorTests.cs | 37 ++++++++++++++++++ .../Microsoft.Maui.Cli/Ai/McpConfigurator.cs | 18 ++++++++- .../Commands/AiCommands.Add.cs | 20 ++++------ .../Commands/AiCommands.Init.cs | 10 ++++- .../Commands/AiCommands.Update.cs | 11 +++++- 6 files changed, 117 insertions(+), 18 deletions(-) diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs index 4b850518e..777b47aad 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs @@ -292,6 +292,27 @@ public void ShouldCreateDefaultClaudeEnvironment_RespectsEnvFilter(string envFil Assert.Equal(expected, AiCommands.ShouldCreateDefaultClaudeEnvironment([envFilter])); } + [Fact] + public void GetAiCommandWorkingDirectory_SubdirectoryUnderGitRoot_ReturnsGitRoot() + { + var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + try + { + var subdirectory = Path.Combine(tempDir, "src", "MyApp"); + Directory.CreateDirectory(Path.Combine(tempDir, ".git")); + Directory.CreateDirectory(subdirectory); + + var workingDir = AiCommands.GetAiCommandWorkingDirectory(subdirectory); + + Assert.Equal(Path.GetFullPath(tempDir), workingDir); + } + finally + { + if (Directory.Exists(tempDir)) + Directory.Delete(tempDir, recursive: true); + } + } + [Fact] public void FileSystemPathComparer_MatchesCurrentPlatformSemantics() { @@ -321,6 +342,24 @@ public void HasInitInstallFailures_DetectsNegativeFileCounts(int[] skillFileCoun Assert.Equal(expected, AiCommands.HasInitInstallFailures(skillFileCounts, assetFileCounts)); } + [Theory] + [InlineData(new[] { 1, 2 }, new[] { 1 }, false)] + [InlineData(new[] { -2, 1 }, new[] { 1 }, true)] + [InlineData(new[] { 1 }, new[] { -1 }, true)] + [InlineData(new[] { 1 }, new[] { 0 }, true)] + public void HasUpdateInstallFailures_DetectsFailedUpdates(int[] skillFileCounts, int[] assetFileCounts, bool expected) + { + Assert.Equal(expected, AiCommands.HasUpdateInstallFailures(skillFileCounts, assetFileCounts)); + } + + [Theory] + [InlineData(false, "success")] + [InlineData(true, "partial_failure")] + public void GetUpdateStatus_ReflectsUpdateFailures(bool hasUpdateFailures, string expected) + { + Assert.Equal(expected, AiCommands.GetUpdateStatus(hasUpdateFailures)); + } + private static void AssertNoWhitespaceAliases(Command command) { foreach (var option in command.Options) diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs index 319c5f252..4e78edc7e 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs @@ -175,6 +175,43 @@ public async Task ConfigureAsync_Idempotent_DoesNotDuplicateEntry() Assert.Equal(contentAfterFirst, contentAfterSecond); } + [Theory] + [InlineData("\"broken\"")] + [InlineData("""{ "command": "wrong" }""")] + [InlineData("""{ "command": "maui", "args": ["wrong"] }""")] + public async Task ConfigureAsync_RepairsMalformedStandardServerEntry(string malformedEntry) + { + var configDir = Path.Combine(_tempDir, ".claude"); + Directory.CreateDirectory(configDir); + var configPath = Path.Combine(configDir, "mcp.json"); + await File.WriteAllTextAsync(configPath, $$""" + { + "mcpServers": { + "maui-devflow": {{malformedEntry}} + } + } + """); + + var env = new DetectedEnvironment + { + Kind = AgentEnvironmentKind.Claude, + McpConfigPath = configPath, + SkillsDirectory = Path.Combine(_tempDir, ".claude", "skills") + }; + + var result = await McpConfigurator.ConfigureAsync(env); + + Assert.True(result); + var json = JsonNode.Parse(await File.ReadAllTextAsync(configPath)); + var server = json?["mcpServers"]?["maui-devflow"]; + Assert.NotNull(server); + Assert.Equal("maui", server["command"]?.GetValue()); + var args = server["args"]?.AsArray(); + Assert.NotNull(args); + Assert.Equal("devflow", args[0]?.GetValue()); + Assert.Equal("mcp", args[1]?.GetValue()); + } + [Fact] public async Task ConfigureAsync_OpenCode_UsesNestedMcpServersKey() { diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs b/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs index b35c74505..a2dc4a349 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs @@ -117,7 +117,7 @@ private static ConfigureResult EnsureStandardEntry(JsonObject root, JsonObject s root["mcpServers"] = mcpServers; } - if (mcpServers[ServerName] is not null) + if (IsExpectedServerEntry(mcpServers[ServerName])) return ConfigureResult.AlreadyConfigured; mcpServers[ServerName] = serverEntry; @@ -149,13 +149,27 @@ private static ConfigureResult EnsureOpenCodeEntry(JsonObject root, JsonObject s mcp["servers"] = servers; } - if (servers[ServerName] is not null) + if (IsExpectedServerEntry(servers[ServerName])) return ConfigureResult.AlreadyConfigured; servers[ServerName] = serverEntry; return ConfigureResult.Updated; } + static bool IsExpectedServerEntry(JsonNode? server) + { + if (server is not JsonObject serverObject) + return false; + + if (serverObject["command"]?.GetValue() != "maui") + return false; + + var args = serverObject["args"] as JsonArray; + return args is { Count: 2 } && + args[0]?.GetValue() == "devflow" && + args[1]?.GetValue() == "mcp"; + } + static async Task WriteAtomicAsync(string configPath, string contents, CancellationToken ct) { var configDir = Path.GetDirectoryName(configPath); diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Add.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Add.cs index cab3f78f9..de307f45a 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Add.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Add.cs @@ -82,16 +82,11 @@ static Command CreateAddCommand() } // Detect environments - var workingDir = Directory.GetCurrentDirectory(); - var environments = AgentEnvironmentDetector.Detect(workingDir); + var currentDir = Directory.GetCurrentDirectory(); + var workingDir = GetAiCommandWorkingDirectory(currentDir); + var environments = AgentEnvironmentDetector.Detect(currentDir); - if (envFilter is { Length: > 0 }) - { - environments = environments - .Where(e => envFilter.Any(f => - string.Equals(f, e.Kind.ToString(), StringComparison.OrdinalIgnoreCase))) - .ToList(); - } + environments = FilterEnvironments(environments, envFilter); if (environments.Count == 0) { @@ -132,7 +127,7 @@ static Command CreateAddCommand() if (filesInstalled == -1) { - formatter.WriteWarning($"Skill '{skill.Name}' has an invalid name and cannot be installed."); + formatter.WriteWarning($"Skill '{skill.Name}' has an invalid name or unsafe install path and cannot be installed."); } else if (filesInstalled == -2) { @@ -159,9 +154,10 @@ static Command CreateAddCommand() if (useJson) { + var hasInstallFailures = HasSkillInstallFailures(results.Select(r => r.Files)); var jsonResult = new JsonObject { - ["status"] = "success", + ["status"] = GetInitStatus(hasInstallFailures), ["skill"] = skill.Name, ["installations"] = new JsonArray(results.Select(r => (JsonNode)new JsonObject { @@ -173,7 +169,7 @@ static Command CreateAddCommand() formatter.Write(jsonResult); } - return 0; + return HasSkillInstallFailures(results.Select(r => r.Files)) ? 1 : 0; } catch (HttpRequestException ex) { diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs index e89888472..06f387258 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs @@ -73,7 +73,7 @@ static Command CreateInitCommand() // Step 2: Detect agent environments var currentDir = Directory.GetCurrentDirectory(); - var workingDir = AgentEnvironmentDetector.ResolveProjectRoot(currentDir); + var workingDir = GetAiCommandWorkingDirectory(currentDir); var environments = AgentEnvironmentDetector.Detect(currentDir); environments = FilterEnvironments(environments, envFilter); @@ -490,11 +490,17 @@ static string FormatFileResult(int files) => }; internal static bool HasInitInstallFailures(IEnumerable skillFileCounts, IEnumerable assetFileCounts) - => skillFileCounts.Any(files => files < 0) || assetFileCounts.Any(files => files < 0); + => HasSkillInstallFailures(skillFileCounts) || assetFileCounts.Any(files => files < 0); internal static string GetInitStatus(bool hasInstallFailures) => hasInstallFailures ? "partial" : "success"; + internal static bool HasSkillInstallFailures(IEnumerable skillFileCounts) + => skillFileCounts.Any(files => files < 0); + + internal static string GetAiCommandWorkingDirectory(string currentDir) + => AgentEnvironmentDetector.ResolveProjectRoot(currentDir); + /// /// Fetches all skills from every plugin listed in the marketplace manifest, /// plus project-scoped GitHub Copilot skills that live in this repository. diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs index 026c3c029..6e3bf39c9 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs @@ -272,11 +272,12 @@ version.Commit is null || formatter.WriteWarning($"Could not update {asset.Name} → {asset.Category}"); } + var hasUpdateFailures = HasUpdateInstallFailures(results.Select(r => r.Files), assetResults.Select(r => r.Files)); if (useJson) { var jsonResult = new JsonObject { - ["status"] = "success", + ["status"] = GetUpdateStatus(hasUpdateFailures), ["devFlowSkills"] = new JsonArray(devFlowResults.Select(r => (JsonNode)new JsonObject { ["skill"] = r.Skill, @@ -307,7 +308,7 @@ version.Commit is null || formatter.WriteWarning($"⚠ Could not check {uncheckableCount} {skillWord} — GitHub may be unreachable."); } - return 0; + return hasUpdateFailures ? 1 : 0; } catch (HttpRequestException ex) { @@ -330,4 +331,10 @@ static string ShortCommit(string? commit) internal static StringComparer FileSystemPathComparer => OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + + internal static bool HasUpdateInstallFailures(IEnumerable skillFileCounts, IEnumerable assetFileCounts) + => skillFileCounts.Any(files => files < 0) || assetFileCounts.Any(files => files <= 0); + + internal static string GetUpdateStatus(bool hasUpdateFailures) + => hasUpdateFailures ? "partial_failure" : "success"; } From 7a1936ac4fea340b37a61d39e8c968be90d2edc4 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Thu, 21 May 2026 20:02:51 +0200 Subject: [PATCH 18/31] Harden MAUI AI asset writes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MarketplaceClientTests.cs | 31 ++++++-------- .../McpConfiguratorTests.cs | 41 ++++++++++++++++++- .../SkillInstallerTests.cs | 22 +++++++++- .../Microsoft.Maui.Cli/Ai/McpConfigurator.cs | 34 ++++++++------- .../Ai/RepositoryAssetInstaller.cs | 8 +++- .../Microsoft.Maui.Cli/Ai/SkillInstaller.cs | 8 +++- .../Commands/AiCommands.Add.cs | 2 +- .../Commands/AiCommands.Init.cs | 2 +- 8 files changed, 105 insertions(+), 43 deletions(-) diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs index 0e4b677e7..c6e22e13c 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs @@ -224,7 +224,7 @@ public void ParseFrontmatter_ContentAfterFrontmatter_IsIgnored() [Fact] public async Task GetSkillsFromDirectoryAsync_DiscoversRepositorySkills() { - var handler = new FakeHttpMessageHandler(new HttpResponseMessage(HttpStatusCode.OK) + var handler = new FakeHttpMessageHandler(() => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(""" --- @@ -255,7 +255,7 @@ public async Task GetSkillsFromDirectoryAsync_DiscoversRepositorySkills() [Fact] public async Task DownloadSkillFilesAsync_RejectsPathTraversal() { - var handler = new FakeHttpMessageHandler(new HttpResponseMessage(HttpStatusCode.OK) + var handler = new FakeHttpMessageHandler(() => new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent("malicious"u8.ToArray()) }); @@ -280,7 +280,7 @@ public async Task DownloadSkillFilesAsync_RejectsPathTraversal() [Fact] public async Task DownloadSkillFilesAsync_RejectsPathTraversal_InRelativePath() { - var handler = new FakeHttpMessageHandler(new HttpResponseMessage(HttpStatusCode.OK) + var handler = new FakeHttpMessageHandler(() => new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent("malicious"u8.ToArray()) }); @@ -304,7 +304,7 @@ public async Task DownloadSkillFilesAsync_RejectsPathTraversal_InRelativePath() [Fact] public async Task DownloadSkillFilesAsync_AllowsSafeRelativePath() { - var handler = new FakeHttpMessageHandler(new HttpResponseMessage(HttpStatusCode.OK) + var handler = new FakeHttpMessageHandler(() => new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent("safe content"u8.ToArray()) }); @@ -328,7 +328,7 @@ public async Task DownloadSkillFilesAsync_AllowsSafeRelativePath() [Fact] public async Task DownloadSkillFilesAsync_RejectsFilesOutsideRemotePath() { - var handler = new FakeHttpMessageHandler(new HttpResponseMessage(HttpStatusCode.OK) + var handler = new FakeHttpMessageHandler(() => new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent("wrong skill"u8.ToArray()) }); @@ -436,7 +436,7 @@ public void EncodeRepoPath_InvalidRepo_Throws(string repo) [Fact] public async Task FetchRawStringAsync_InvalidRepo_ThrowsBeforeHttpCall() { - var handler = new FakeHttpMessageHandler(new HttpResponseMessage(HttpStatusCode.OK) + var handler = new FakeHttpMessageHandler(() => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("content") }); @@ -454,7 +454,7 @@ await Assert.ThrowsAsync(() => MarketplaceClient.Fetc [Fact] public async Task GetRemoteCommitShaAsync_InvalidRepo_ThrowsBeforeHttpCall() { - var handler = new FakeHttpMessageHandler(new HttpResponseMessage(HttpStatusCode.OK) + var handler = new FakeHttpMessageHandler(() => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("[]") }); @@ -472,7 +472,7 @@ await Assert.ThrowsAsync(() => MarketplaceClient.GetR [Fact] public async Task FetchRawStringAsync_OversizedResponse_ReturnsNull() { - var handler = new FakeHttpMessageHandler(new HttpResponseMessage(HttpStatusCode.OK) + var handler = new FakeHttpMessageHandler(() => new HttpResponseMessage(HttpStatusCode.OK) { Content = new OversizedHttpContent() }); @@ -486,7 +486,7 @@ public async Task FetchRawStringAsync_OversizedResponse_ReturnsNull() [Fact] public async Task FetchRawBytesAsync_OversizedResponse_ReturnsNull() { - var handler = new FakeHttpMessageHandler(new HttpResponseMessage(HttpStatusCode.OK) + var handler = new FakeHttpMessageHandler(() => new HttpResponseMessage(HttpStatusCode.OK) { Content = new OversizedHttpContent() }); @@ -498,24 +498,19 @@ public async Task FetchRawBytesAsync_OversizedResponse_ReturnsNull() } /// - /// Minimal HttpMessageHandler that returns a fixed response for every request. + /// Minimal HttpMessageHandler that returns a fresh response for every request. /// private sealed class FakeHttpMessageHandler : HttpMessageHandler { - private readonly HttpResponseMessage _response; + private readonly Func _responseFactory; public int Calls { get; private set; } - public FakeHttpMessageHandler(HttpResponseMessage response) => _response = response; + public FakeHttpMessageHandler(Func responseFactory) => _responseFactory = responseFactory; protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { Calls++; - return Task.FromResult(_response); - } - - protected override void Dispose(bool disposing) - { - // Don't dispose _response — the caller owns it via the test scope + return Task.FromResult(_responseFactory()); } } diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs index 4e78edc7e..b00d02312 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs @@ -140,7 +140,8 @@ await File.WriteAllTextAsync(configPath, """ var result = await McpConfigurator.ConfigureAsync(env); Assert.True(result); - var backup = await File.ReadAllTextAsync(configPath + ".bak"); + var backupPath = Assert.Single(Directory.GetFiles(configDir, "mcp.json.*.bak")); + var backup = await File.ReadAllTextAsync(backupPath); Assert.Contains("// Existing user MCP server.", backup); var json = JsonNode.Parse(await File.ReadAllTextAsync(configPath)); @@ -343,6 +344,31 @@ public async Task ConfigureAsync_IncompatibleStandardSchema_ReturnsFalseAndLeave Assert.Empty(Directory.EnumerateFiles(configDir, "*.tmp")); } + [Fact] + public async Task ConfigureAsync_SymlinkedProjectConfigDirectoryOutsideProject_ReturnsFalse() + { + var projectRoot = Path.Combine(_tempDir, "project"); + var outsideRoot = Path.Combine(_tempDir, "outside"); + Directory.CreateDirectory(projectRoot); + Directory.CreateDirectory(outsideRoot); + + if (!TryCreateDirectorySymlink(Path.Combine(projectRoot, ".claude"), outsideRoot)) + return; + + var configPath = Path.Combine(projectRoot, ".claude", "mcp.json"); + var env = new DetectedEnvironment + { + Kind = AgentEnvironmentKind.Claude, + McpConfigPath = configPath, + SkillsDirectory = Path.Combine(projectRoot, ".claude", "skills") + }; + + var result = await McpConfigurator.ConfigureAsync(env, projectRoot); + + Assert.False(result); + Assert.False(File.Exists(Path.Combine(outsideRoot, "mcp.json"))); + } + [Fact] public async Task ConfigureAsync_IncompatibleOpenCodeSchema_ReturnsFalseAndLeavesConfigUnchanged() { @@ -394,4 +420,17 @@ public async Task ConfigureAsync_ReturnsTrue_WhenEntryAlreadyExists() var second = await McpConfigurator.ConfigureAsync(env); Assert.True(second); } + + static bool TryCreateDirectorySymlink(string linkPath, string targetPath) + { + try + { + Directory.CreateSymbolicLink(linkPath, targetPath); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return false; + } + } } diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs index d812592b6..0d439e5d3 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs @@ -58,6 +58,26 @@ public async Task InstallSkillAsync_InvalidName_PathSeparator_ReturnsNegativeOne Assert.Equal(string.Empty, installPath); } + [Theory] + [InlineData(".")] + [InlineData("bad/name")] + [InlineData("bad\\name")] + public async Task InstallSkillAsync_InvalidName_EdgeCase_ReturnsNegativeOne(string skillName) + { + var skill = new SkillInfo { Name = skillName }; + var env = new DetectedEnvironment + { + Kind = AgentEnvironmentKind.Claude, + SkillsDirectory = Path.Combine(_tempDir, "skills") + }; + + var (filesInstalled, installPath) = await SkillInstaller.InstallSkillAsync( + new HttpClient(), skill, env, _tempDir, "owner/repo", "main", force: false); + + Assert.Equal(-1, filesInstalled); + Assert.Equal(string.Empty, installPath); + } + [Fact] public async Task InstallSkillAsync_SymlinkedSkillsDirectoryOutsideProject_ReturnsNegativeOne() { @@ -143,7 +163,7 @@ public async Task InstallSkillAsync_PartialDownload_RollsBackAndReturnsNegativeT http, skill, env, _tempDir, "owner/repo", "main", force: false); Assert.Equal(-2, filesInstalled); - Assert.False(Directory.Exists(installPath)); + Assert.Equal(string.Empty, installPath); Assert.Empty(Directory.EnumerateFileSystemEntries(skillsDir)); } diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs b/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs index a2dc4a349..ebb33ac5c 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs @@ -36,11 +36,25 @@ enum ConfigureResult /// Target agent environment. /// Cancellation token. /// true if the configuration is in place; false on failure. - public static async Task ConfigureAsync(DetectedEnvironment env, CancellationToken ct = default) + public static Task ConfigureAsync(DetectedEnvironment env, CancellationToken ct = default) + => ConfigureAsync(env, projectRoot: null, ct); + + public static async Task ConfigureAsync(DetectedEnvironment env, string? projectRoot, CancellationToken ct = default) { try { var configPath = env.McpConfigPath; + var configDir = Path.GetDirectoryName(configPath); + if (!string.IsNullOrEmpty(configDir)) + { + Directory.CreateDirectory(configDir); + if (projectRoot is not null && + env.Kind != AgentEnvironmentKind.CopilotCli && + !FileSystemPathGuard.IsPathWithinRoot(configDir, projectRoot)) + { + return false; + } + } JsonObject root; string? existingJson = null; @@ -74,11 +88,6 @@ public static async Task ConfigureAsync(DetectedEnvironment env, Cancellat if (configureResult == ConfigureResult.IncompatibleSchema) return false; - // Ensure the config directory exists before writing. - var configDir = Path.GetDirectoryName(configPath); - if (!string.IsNullOrEmpty(configDir)) - Directory.CreateDirectory(configDir); - var options = new JsonSerializerOptions { WriteIndented = true }; if (backupExistingConfig && existingJson is not null) await WriteBackupAsync(configPath, existingJson, ct).ConfigureAwait(false); @@ -194,18 +203,7 @@ static async Task WriteBackupAsync(string configPath, string contents, Cancellat } static string GetBackupPath(string configPath) - { - var backupPath = configPath + ".bak"; - if (!File.Exists(backupPath)) - return backupPath; - - for (var i = 1; ; i++) - { - var candidate = $"{configPath}.{i}.bak"; - if (!File.Exists(candidate)) - return candidate; - } - } + => $"{configPath}.{Guid.NewGuid():N}.bak"; static bool ContainsJsonComments(string contents) { diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/RepositoryAssetInstaller.cs b/src/Cli/Microsoft.Maui.Cli/Ai/RepositoryAssetInstaller.cs index b7e672b74..25fa42b47 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/RepositoryAssetInstaller.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/RepositoryAssetInstaller.cs @@ -77,8 +77,11 @@ public static async Task> GetCopilotAgentsAsync( if (!FileSystemPathGuard.IsPathWithinRoot(destinationRoot, projectRoot)) return (-1, string.Empty); - var destinationBase = Path.GetFullPath(destinationRoot) + Path.DirectorySeparatorChar; Directory.CreateDirectory(destinationRoot); + if (!FileSystemPathGuard.IsPathWithinRoot(destinationRoot, projectRoot)) + return (-1, string.Empty); + + var destinationBase = Path.GetFullPath(destinationRoot) + Path.DirectorySeparatorChar; var count = 0; foreach (var filePath in asset.Files) @@ -95,6 +98,9 @@ public static async Task> GetCopilotAgentsAsync( if (content is null) continue; + if (!FileSystemPathGuard.IsPathWithinRoot(Path.GetDirectoryName(fullDestinationPath) ?? destinationRoot, projectRoot)) + return (-1, string.Empty); + await File.WriteAllBytesAsync(fullDestinationPath, content, ct).ConfigureAwait(false); count++; } diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs b/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs index 11079c13c..45bb6f0f2 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs @@ -40,7 +40,11 @@ internal static class SkillInstaller bool force, CancellationToken ct = default) { - if (skill.Name.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || skill.Name.Contains("..")) + if (skill.Name is "." or ".." || + skill.Name.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || + skill.Name.Contains("..") || + skill.Name.Contains('/') || + skill.Name.Contains('\\')) return (-1, string.Empty); // If the skills directory is not rooted, resolve it relative to the project root. @@ -71,7 +75,7 @@ internal static class SkillInstaller http, skill, tempInstallPath, repo, branch, ct).ConfigureAwait(false); if (skill.Files.Count == 0 || filesInstalled != skill.Files.Count) - return (-2, installPath); + return (-2, string.Empty); // Resolve the latest commit SHA for version tracking. var commitSha = await MarketplaceClient.GetRemoteCommitShaAsync( diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Add.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Add.cs index de307f45a..f78bb2614 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Add.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Add.cs @@ -144,7 +144,7 @@ static Command CreateAddCommand() { foreach (var env in environments) { - var ok = await McpConfigurator.ConfigureAsync(env, ct); + var ok = await McpConfigurator.ConfigureAsync(env, workingDir, ct); if (ok) formatter.WriteSuccess($"MCP configured for {env.Kind}"); else diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs index 06f387258..5f731e8b4 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs @@ -271,7 +271,7 @@ static Command CreateInitCommand() { foreach (var env in environments) { - var ok = await McpConfigurator.ConfigureAsync(env, ct); + var ok = await McpConfigurator.ConfigureAsync(env, workingDir, ct); if (ok) formatter.WriteSuccess($"MCP configured for {env.Kind}"); else From 921a3a3534c1d1e03c77a85dbe61ef9b06394e3a Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Thu, 21 May 2026 20:24:25 +0200 Subject: [PATCH 19/31] Tighten MAUI AI path and agent matching Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../McpConfiguratorTests.cs | 20 +++++++++++++++++++ .../RepositoryAssetInstallerTests.cs | 2 ++ .../Microsoft.Maui.Cli/Ai/McpConfigurator.cs | 3 ++- .../Ai/RepositoryAssetInstaller.cs | 8 ++++---- 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs index b00d02312..e5a8ce0aa 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs @@ -369,6 +369,26 @@ public async Task ConfigureAsync_SymlinkedProjectConfigDirectoryOutsideProject_R Assert.False(File.Exists(Path.Combine(outsideRoot, "mcp.json"))); } + [Fact] + public async Task ConfigureAsync_PathOutsideProject_DoesNotCreateDirectory() + { + var projectRoot = Path.Combine(_tempDir, "project"); + var outsideRoot = Path.Combine(_tempDir, "outside"); + Directory.CreateDirectory(projectRoot); + var configPath = Path.Combine(outsideRoot, ".claude", "mcp.json"); + var env = new DetectedEnvironment + { + Kind = AgentEnvironmentKind.Claude, + McpConfigPath = configPath, + SkillsDirectory = Path.Combine(projectRoot, ".claude", "skills") + }; + + var result = await McpConfigurator.ConfigureAsync(env, projectRoot); + + Assert.False(result); + Assert.False(Directory.Exists(Path.Combine(outsideRoot, ".claude"))); + } + [Fact] public async Task ConfigureAsync_IncompatibleOpenCodeSchema_ReturnsFalseAndLeavesConfigUnchanged() { diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/RepositoryAssetInstallerTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/RepositoryAssetInstallerTests.cs index c92dcd952..876da326a 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/RepositoryAssetInstallerTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/RepositoryAssetInstallerTests.cs @@ -42,6 +42,7 @@ public async Task GetCopilotAgentsAsync_ReturnsOnlyMauiRelatedAgents() description: Generic workflow helper. --- # Generic + This agent does not cover maui-specific logic. """ })); var treeEntries = new List<(string Path, string Type)> @@ -136,6 +137,7 @@ public void GetInstalledCopilotAgents_ReturnsOnlyMauiRelatedAgents() description: Generic workflow helper. --- # Generic + This agent does not cover maui-specific logic. """); var assets = RepositoryAssetInstaller.GetInstalledCopilotAgents(_tempDir); diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs b/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs index ebb33ac5c..e8a964984 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs @@ -47,13 +47,14 @@ public static async Task ConfigureAsync(DetectedEnvironment env, string? p var configDir = Path.GetDirectoryName(configPath); if (!string.IsNullOrEmpty(configDir)) { - Directory.CreateDirectory(configDir); if (projectRoot is not null && env.Kind != AgentEnvironmentKind.CopilotCli && !FileSystemPathGuard.IsPathWithinRoot(configDir, projectRoot)) { return false; } + + Directory.CreateDirectory(configDir); } JsonObject root; diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/RepositoryAssetInstaller.cs b/src/Cli/Microsoft.Maui.Cli/Ai/RepositoryAssetInstaller.cs index 25fa42b47..7771a89e4 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/RepositoryAssetInstaller.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/RepositoryAssetInstaller.cs @@ -44,7 +44,7 @@ public static async Task> GetCopilotAgentsAsync( var (name, description) = MarketplaceClient.ParseFrontmatter(content); var assetName = name ?? GetRemoteFileName(entryPath)[..^".agent.md".Length]; - if (!IsMauiRelatedAgent(assetName, description, content)) + if (!IsMauiRelatedAgent(assetName, description)) continue; assets.Add(new RepositoryAssetInfo @@ -125,7 +125,7 @@ public static List GetInstalledCopilotAgents(string project var (name, description) = MarketplaceClient.ParseFrontmatter(content); var fileName = Path.GetFileName(filePath); var assetName = name ?? fileName[..^".agent.md".Length]; - if (!IsMauiRelatedAgent(assetName, description, content)) + if (!IsMauiRelatedAgent(assetName, description)) continue; assets.Add(new RepositoryAssetInfo @@ -153,9 +153,9 @@ static string GetDestinationRoot(string projectRoot, string destinationRoot) projectRoot, MarketplaceClient.NormalizePath(destinationRoot).Replace('/', Path.DirectorySeparatorChar)); - static bool IsMauiRelatedAgent(string name, string? description, string content) + static bool IsMauiRelatedAgent(string name, string? description) { - var haystack = string.Join('\n', name, description, content); + var haystack = string.Join('\n', name, description); return haystack.Contains("maui", StringComparison.OrdinalIgnoreCase) || haystack.Contains("comet", StringComparison.OrdinalIgnoreCase); } From e8d38a3778df33ce5767cbc690b09526db8f9511 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Thu, 21 May 2026 20:46:41 +0200 Subject: [PATCH 20/31] Resolve MAUI AI review edge cases Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AgentEnvironmentDetectorTests.cs | 24 ++++++++++ .../AiCommandsTests.cs | 1 + .../SkillInstallerTests.cs | 48 +++++++++++++++++++ .../Ai/AgentEnvironmentDetector.cs | 37 +++++++++----- .../Microsoft.Maui.Cli/Ai/SkillInstaller.cs | 39 +++++++++++++-- .../Commands/AiCommands.Init.cs | 16 +++---- .../Commands/AiCommands.Update.cs | 2 +- 7 files changed, 142 insertions(+), 25 deletions(-) diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/AgentEnvironmentDetectorTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/AgentEnvironmentDetectorTests.cs index 88d67bc5e..6b76e77d9 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/AgentEnvironmentDetectorTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/AgentEnvironmentDetectorTests.cs @@ -222,4 +222,28 @@ public void Detect_NoGitRoot_DoesNotScanAncestorDirectories() Assert.DoesNotContain(environments, e => e.Kind == AgentEnvironmentKind.VsCode); } + + [Fact] + public void GetCopilotCliEnvironment_EmptyUserHome_ReturnsNull() + { + Directory.CreateDirectory(Path.Combine(_tempDir, ".copilot")); + + var environment = AgentEnvironmentDetector.GetCopilotCliEnvironment(string.Empty, _tempDir); + + Assert.Null(environment); + } + + [Fact] + public void GetCopilotCliEnvironment_ValidUserHome_ReturnsCopilotCliEnvironment() + { + var userHome = Path.Combine(_tempDir, "home"); + Directory.CreateDirectory(Path.Combine(userHome, ".copilot")); + + var environment = AgentEnvironmentDetector.GetCopilotCliEnvironment(userHome, _tempDir); + + Assert.NotNull(environment); + Assert.Equal(AgentEnvironmentKind.CopilotCli, environment.Kind); + Assert.Equal(Path.Combine(_tempDir, ".github", "skills"), environment.SkillsDirectory); + Assert.Equal(Path.Combine(userHome, ".copilot", "mcp.json"), environment.McpConfigPath); + } } diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs index 777b47aad..ee38ba387 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs @@ -345,6 +345,7 @@ public void HasInitInstallFailures_DetectsNegativeFileCounts(int[] skillFileCoun [Theory] [InlineData(new[] { 1, 2 }, new[] { 1 }, false)] [InlineData(new[] { -2, 1 }, new[] { 1 }, true)] + [InlineData(new[] { 0, 1 }, new[] { 1 }, true)] [InlineData(new[] { 1 }, new[] { -1 }, true)] [InlineData(new[] { 1 }, new[] { 0 }, true)] public void HasUpdateInstallFailures_DetectsFailedUpdates(int[] skillFileCounts, int[] assetFileCounts, bool expected) diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs index 0d439e5d3..efa941283 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs @@ -167,6 +167,54 @@ public async Task InstallSkillAsync_PartialDownload_RollsBackAndReturnsNegativeT Assert.Empty(Directory.EnumerateFileSystemEntries(skillsDir)); } + [Fact] + public async Task InstallSkillAsync_FileOutsideRemotePath_DoesNotCountSkippedFileAsFailure() + { + var skill = new SkillInfo + { + Name = "filtered-skill", + RemotePath = ".github/skills/filtered-skill", + Files = + [ + ".github/skills/filtered-skill/SKILL.md", + ".github/skills/other-skill/SKILL.md" + ] + }; + var skillsDir = Path.Combine(_tempDir, "skills"); + var env = new DetectedEnvironment + { + Kind = AgentEnvironmentKind.Claude, + SkillsDirectory = skillsDir + }; + using var http = new HttpClient(new SuccessfulInstallHandler()); + + var (filesInstalled, installPath) = await SkillInstaller.InstallSkillAsync( + http, skill, env, _tempDir, "owner/repo", "main", force: false); + + Assert.Equal(1, filesInstalled); + Assert.Equal(Path.Combine(skillsDir, skill.Name), installPath); + Assert.True(File.Exists(Path.Combine(installPath, "SKILL.md"))); + } + + [Fact] + public void GetExpectedDownloadableFileCount_CountsOnlyFilesUnderRemotePath() + { + var skill = new SkillInfo + { + Name = "filtered-skill", + RemotePath = ".github/skills/filtered-skill", + Files = + [ + ".github/skills/filtered-skill/SKILL.md", + ".github/skills/filtered-skill/references/setup.md", + ".github/skills/other-skill/SKILL.md", + "../escape.md" + ] + }; + + Assert.Equal(2, SkillInstaller.GetExpectedDownloadableFileCount(skill)); + } + [Fact] public async Task InstallSkillAsync_DownloadThrows_RemovesTempInstallDirectory() { diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/AgentEnvironmentDetector.cs b/src/Cli/Microsoft.Maui.Cli/Ai/AgentEnvironmentDetector.cs index b97edd695..5cb325620 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/AgentEnvironmentDetector.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/AgentEnvironmentDetector.cs @@ -83,19 +83,11 @@ public static List Detect(string workingDir) } // Copilot CLI is detected at the user level. - var userHome = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - var copilotDir = Path.Combine(userHome, ".copilot"); - if (Directory.Exists(copilotDir)) - { - var mcpPath = Path.Combine(copilotDir, "mcp.json"); - environments.Add(new DetectedEnvironment - { - Kind = AgentEnvironmentKind.CopilotCli, - SkillsDirectory = Path.Combine(searchRoot, ".github", "skills"), - McpConfigPath = mcpPath, - McpConfigExists = File.Exists(mcpPath) - }); - } + var copilotCliEnvironment = GetCopilotCliEnvironment( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + searchRoot); + if (copilotCliEnvironment is not null) + environments.Add(copilotCliEnvironment); return environments; } @@ -126,6 +118,25 @@ private static bool IsGitRoot(string directory) return Directory.Exists(gitPath) || File.Exists(gitPath); } + internal static DetectedEnvironment? GetCopilotCliEnvironment(string userHome, string searchRoot) + { + if (string.IsNullOrEmpty(userHome)) + return null; + + var copilotDir = Path.Combine(userHome, ".copilot"); + if (!Directory.Exists(copilotDir)) + return null; + + var mcpPath = Path.Combine(copilotDir, "mcp.json"); + return new DetectedEnvironment + { + Kind = AgentEnvironmentKind.CopilotCli, + SkillsDirectory = Path.Combine(searchRoot, ".github", "skills"), + McpConfigPath = mcpPath, + McpConfigExists = File.Exists(mcpPath) + }; + } + static StringComparison PathComparison => OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; } diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs b/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs index 45bb6f0f2..3ba4731e7 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs @@ -27,8 +27,8 @@ internal static class SkillInstaller /// A tuple of (filesInstalled, installPath) where filesInstalled is the number /// of files written and installPath is the absolute path to the skill directory. /// Returns (0, installPath) if the skill is already installed and is false. - /// Returns (-1, string.Empty) if the skill name contains invalid characters or targets an unsafe path. - /// Returns (-2, installPath) if the download produced zero files (network or remote failure). + /// Returns (-1, string.Empty) if the skill name contains invalid characters or targets an unsafe path. + /// Returns (-2, string.Empty) if the download produced no valid installation (network or remote failure). /// public static async Task<(int FilesInstalled, string InstallPath)> InstallSkillAsync( HttpClient http, @@ -66,15 +66,19 @@ internal static class SkillInstaller } Directory.CreateDirectory(skillsDir); + if (!FileSystemPathGuard.IsPathWithinRoot(skillsDir, projectRoot)) + return (-1, string.Empty); + var tempInstallPath = Path.Combine(skillsDir, $".{skill.Name}.{Guid.NewGuid():N}.tmp"); Directory.CreateDirectory(tempInstallPath); try { + var expectedFileCount = GetExpectedDownloadableFileCount(skill); var filesInstalled = await MarketplaceClient.DownloadSkillFilesAsync( http, skill, tempInstallPath, repo, branch, ct).ConfigureAwait(false); - if (skill.Files.Count == 0 || filesInstalled != skill.Files.Count) + if (expectedFileCount == 0 || filesInstalled != expectedFileCount) return (-2, string.Empty); // Resolve the latest commit SHA for version tracking. @@ -103,6 +107,35 @@ internal static class SkillInstaller } } + internal static int GetExpectedDownloadableFileCount(SkillInfo skill) + { + var count = 0; + string remotePrefix; + try + { + remotePrefix = MarketplaceClient.NormalizePath(skill.RemotePath) + "/"; + } + catch (InvalidOperationException) + { + return 0; + } + + foreach (var filePath in skill.Files) + { + try + { + if (MarketplaceClient.NormalizePath(filePath).StartsWith(remotePrefix, StringComparison.Ordinal)) + count++; + } + catch (InvalidOperationException) + { + // Invalid repository paths are intentionally skipped by the downloader. + } + } + + return count; + } + static void ReplaceDirectory(string sourceDirectory, string destinationDirectory) { var backupDirectory = $"{destinationDirectory}.{Guid.NewGuid():N}.bak"; diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs index 5f731e8b4..f3eec5e13 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs @@ -289,14 +289,6 @@ static Command CreateInitCommand() summaryRows.AddRange(assetResults.Select(r => (r.Asset, r.Type, "GitHub Copilot", FormatFileResult(r.Files), r.Path))); var hasInstallFailures = HasInitInstallFailures(skillResults.Select(r => r.Files), assetResults.Select(r => r.Files)); - formatter.WriteTable( - summaryRows, - ("Item", r => r.Item), - ("Type", r => r.Type), - ("Target", r => r.Target), - ("Result", r => r.Result), - ("Path", r => r.Path)); - if (useJson) { var jsonResult = new JsonObject @@ -328,6 +320,14 @@ static Command CreateInitCommand() } else { + formatter.WriteTable( + summaryRows, + ("Item", r => r.Item), + ("Type", r => r.Type), + ("Target", r => r.Target), + ("Result", r => r.Result), + ("Path", r => r.Path)); + AnsiConsole.WriteLine(); AnsiConsole.MarkupLine("[dim]Next steps:[/]"); AnsiConsole.MarkupLine("[dim] Open your AI agent and ask it to use the installed .NET MAUI skills.[/]"); diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs index 6e3bf39c9..4ca572541 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs @@ -333,7 +333,7 @@ static string ShortCommit(string? commit) OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; internal static bool HasUpdateInstallFailures(IEnumerable skillFileCounts, IEnumerable assetFileCounts) - => skillFileCounts.Any(files => files < 0) || assetFileCounts.Any(files => files <= 0); + => skillFileCounts.Any(files => files <= 0) || assetFileCounts.Any(files => files <= 0); internal static string GetUpdateStatus(bool hasUpdateFailures) => hasUpdateFailures ? "partial_failure" : "success"; From 2c68c43e34c556ab47d3fc04633f84d3645ed0c2 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Thu, 21 May 2026 20:48:29 +0200 Subject: [PATCH 21/31] Preserve MAUI AI agent asset paths Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../RepositoryAssetInstallerTests.cs | 48 +++++++++++++++++++ .../Ai/RepositoryAssetInstaller.cs | 23 +++++++-- 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/RepositoryAssetInstallerTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/RepositoryAssetInstallerTests.cs index 876da326a..edb99bb29 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/RepositoryAssetInstallerTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/RepositoryAssetInstallerTests.cs @@ -174,6 +174,54 @@ public async Task InstallAssetAsync_ForceOverwritesExistingAgent() Assert.Equal("updated agent content", await File.ReadAllTextAsync(localPath)); } + [Fact] + public async Task InstallAssetAsync_PreservesRelativePathsToAvoidFileNameCollisions() + { + using var http = new HttpClient(new MapHttpMessageHandler(new Dictionary + { + [".github/agents/ios/expert-reviewer.agent.md"] = "ios agent", + [".github/agents/android/expert-reviewer.agent.md"] = "android agent" + })); + var asset = new RepositoryAssetInfo + { + Name = "expert-reviewer", + Category = "agent", + RemotePath = ".github/agents/ios/expert-reviewer.agent.md", + DestinationRoot = ".github/agents", + Files = + [ + ".github/agents/ios/expert-reviewer.agent.md", + ".github/agents/android/expert-reviewer.agent.md" + ] + }; + + var result = await RepositoryAssetInstaller.InstallAssetAsync( + http, asset, _tempDir, "owner/repo", "main", force: true); + + Assert.Equal(2, result.FilesInstalled); + Assert.Equal("ios agent", await File.ReadAllTextAsync(Path.Combine(_tempDir, ".github", "agents", "ios", "expert-reviewer.agent.md"))); + Assert.Equal("android agent", await File.ReadAllTextAsync(Path.Combine(_tempDir, ".github", "agents", "android", "expert-reviewer.agent.md"))); + } + + [Fact] + public void GetInstalledCopilotAgents_IncludesNestedAgents() + { + var agentsDir = Path.Combine(_tempDir, ".github", "agents", "nested"); + Directory.CreateDirectory(agentsDir); + File.WriteAllText(Path.Combine(agentsDir, "expert-reviewer.agent.md"), """ + --- + name: expert-reviewer + description: Expert .NET MAUI DevFlow code reviewer. + --- + # Expert Reviewer + """); + + var assets = RepositoryAssetInstaller.GetInstalledCopilotAgents(_tempDir); + + var asset = Assert.Single(assets); + Assert.Equal("expert-reviewer", asset.Name); + } + sealed class MapHttpMessageHandler(Dictionary responses) : HttpMessageHandler { protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/RepositoryAssetInstaller.cs b/src/Cli/Microsoft.Maui.Cli/Ai/RepositoryAssetInstaller.cs index 7771a89e4..fa0bb09a6 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/RepositoryAssetInstaller.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/RepositoryAssetInstaller.cs @@ -98,7 +98,12 @@ public static async Task> GetCopilotAgentsAsync( if (content is null) continue; - if (!FileSystemPathGuard.IsPathWithinRoot(Path.GetDirectoryName(fullDestinationPath) ?? destinationRoot, projectRoot)) + var destinationDirectory = Path.GetDirectoryName(fullDestinationPath) ?? destinationRoot; + if (!FileSystemPathGuard.IsPathWithinRoot(destinationDirectory, projectRoot)) + return (-1, string.Empty); + + Directory.CreateDirectory(destinationDirectory); + if (!FileSystemPathGuard.IsPathWithinRoot(destinationDirectory, projectRoot)) return (-1, string.Empty); await File.WriteAllBytesAsync(fullDestinationPath, content, ct).ConfigureAwait(false); @@ -118,7 +123,7 @@ public static List GetInstalledCopilotAgents(string project if (!Directory.Exists(destinationRoot)) return assets; - foreach (var filePath in Directory.GetFiles(destinationRoot, "*.agent.md", SearchOption.TopDirectoryOnly) + foreach (var filePath in Directory.GetFiles(destinationRoot, "*.agent.md", SearchOption.AllDirectories) .OrderBy(path => path, StringComparer.Ordinal)) { var content = File.ReadAllText(filePath); @@ -145,7 +150,19 @@ public static List GetInstalledCopilotAgents(string project internal static string GetAssetFilePath(string projectRoot, RepositoryAssetInfo asset, string filePath) { var destinationRoot = GetDestinationRoot(projectRoot, asset.DestinationRoot); - return Path.Combine(destinationRoot, GetRemoteFileName(filePath)); + return Path.Combine( + destinationRoot, + GetAssetRelativePath(asset, filePath).Replace('/', Path.DirectorySeparatorChar)); + } + + internal static string GetAssetRelativePath(RepositoryAssetInfo asset, string filePath) + { + var normalizedFilePath = MarketplaceClient.NormalizePath(filePath); + var destinationPrefix = MarketplaceClient.NormalizePath(asset.DestinationRoot) + "/"; + + return normalizedFilePath.StartsWith(destinationPrefix, StringComparison.Ordinal) + ? normalizedFilePath[destinationPrefix.Length..] + : GetRemoteFileName(normalizedFilePath); } static string GetDestinationRoot(string projectRoot, string destinationRoot) From 87d1e40d7dd29524bab41f5edc9f00587890bb6a Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Thu, 21 May 2026 21:13:30 +0200 Subject: [PATCH 22/31] Harden MAUI AI update review fixes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AiCommandsTests.cs | 31 +++++++++ .../MarketplaceClientTests.cs | 2 +- .../RepositoryAssetInstallerTests.cs | 66 +++++++++++++++++++ .../Ai/MarketplaceClient.cs | 4 +- .../Ai/RepositoryAssetInstaller.cs | 52 +++++++++++++-- .../Commands/AiCommands.Init.cs | 4 +- .../Commands/AiCommands.Update.cs | 29 +++++++- .../Microsoft.Maui.Cli/Commands/AiCommands.cs | 8 ++- 8 files changed, 183 insertions(+), 13 deletions(-) diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs index ee38ba387..6b5d1d4a0 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs @@ -337,6 +337,7 @@ public void GetInitStatus_ReflectsInstallFailures(bool hasInstallFailures, strin [InlineData(new[] { 1, 2 }, new[] { 0 }, false)] [InlineData(new[] { -2, 1 }, new[] { 0 }, true)] [InlineData(new[] { 1 }, new[] { -1 }, true)] + [InlineData(new[] { 1 }, new[] { -2 }, true)] public void HasInitInstallFailures_DetectsNegativeFileCounts(int[] skillFileCounts, int[] assetFileCounts, bool expected) { Assert.Equal(expected, AiCommands.HasInitInstallFailures(skillFileCounts, assetFileCounts)); @@ -353,6 +354,36 @@ public void HasUpdateInstallFailures_DetectsFailedUpdates(int[] skillFileCounts, Assert.Equal(expected, AiCommands.HasUpdateInstallFailures(skillFileCounts, assetFileCounts)); } + [Theory] + [InlineData(false, 0, 0, 0, true)] + [InlineData(true, 1, 0, 0, true)] + [InlineData(true, 0, 1, 0, true)] + [InlineData(true, 0, 0, 1, true)] + [InlineData(true, 0, 0, 0, false)] + public void HasUpdateFilterMatches_RequiresAtLeastOneMatchedTargetWhenFiltered( + bool filterSpecified, + int devFlowTargetCount, + int selectedAgentAssetCount, + int installedSkillMatchCount, + bool expected) + { + Assert.Equal( + expected, + AiCommands.HasUpdateFilterMatches( + filterSpecified, + devFlowTargetCount, + selectedAgentAssetCount, + installedSkillMatchCount)); + } + + [Fact] + public void CreateGitHubHttpClient_ConfiguresTimeout() + { + using var http = AiCommands.CreateGitHubHttpClient(); + + Assert.Equal(AiCommands.GitHubHttpTimeout, http.Timeout); + } + [Theory] [InlineData(false, "success")] [InlineData(true, "partial_failure")] diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs index c6e22e13c..6d29be9d6 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs @@ -534,7 +534,7 @@ protected override Task SendAsync(HttpRequestMessage reques private sealed class OversizedHttpContent : HttpContent { protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) - => Task.CompletedTask; + => throw new InvalidOperationException("Oversized responses should be rejected from headers before the body is buffered."); protected override bool TryComputeLength(out long length) { diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/RepositoryAssetInstallerTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/RepositoryAssetInstallerTests.cs index edb99bb29..86a9f63ce 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/RepositoryAssetInstallerTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/RepositoryAssetInstallerTests.cs @@ -119,6 +119,59 @@ public async Task InstallAssetAsync_SymlinkedDestinationRootOutsideProject_Retur Assert.False(File.Exists(Path.Combine(outsideRoot, "agents", "expert-reviewer.agent.md"))); } + [Fact] + public async Task InstallAssetAsync_ExistingSymlinkDestination_ReturnsNegativeOne() + { + var agentsDir = Path.Combine(_tempDir, ".github", "agents"); + Directory.CreateDirectory(agentsDir); + var outsideFile = Path.Combine(_tempDir, "outside-agent.agent.md"); + await File.WriteAllTextAsync(outsideFile, "outside content"); + var symlinkPath = Path.Combine(agentsDir, "expert-reviewer.agent.md"); + if (!TryCreateFileSymlink(symlinkPath, outsideFile)) + return; + + using var http = new HttpClient(new MapHttpMessageHandler(new Dictionary + { + ["expert-reviewer.agent.md"] = "updated agent content" + })); + var asset = new RepositoryAssetInfo + { + Name = "expert-reviewer", + Category = "agent", + RemotePath = ".github/agents/expert-reviewer.agent.md", + DestinationRoot = ".github/agents", + Files = [".github/agents/expert-reviewer.agent.md"] + }; + + var result = await RepositoryAssetInstaller.InstallAssetAsync( + http, asset, _tempDir, "owner/repo", "main", force: true); + + Assert.Equal(-1, result.FilesInstalled); + Assert.Equal(string.Empty, result.InstallPath); + Assert.Equal("outside content", await File.ReadAllTextAsync(outsideFile)); + } + + [Fact] + public async Task InstallAssetAsync_DownloadFailure_ReturnsNegativeTwo() + { + using var http = new HttpClient(new MapHttpMessageHandler(new Dictionary())); + var asset = new RepositoryAssetInfo + { + Name = "expert-reviewer", + Category = "agent", + RemotePath = ".github/agents/expert-reviewer.agent.md", + DestinationRoot = ".github/agents", + Files = [".github/agents/expert-reviewer.agent.md"] + }; + + var result = await RepositoryAssetInstaller.InstallAssetAsync( + http, asset, _tempDir, "owner/repo", "main", force: true); + + Assert.Equal(-2, result.FilesInstalled); + Assert.Equal(Path.Combine(_tempDir, ".github", "agents"), result.InstallPath); + Assert.False(File.Exists(Path.Combine(_tempDir, ".github", "agents", "expert-reviewer.agent.md"))); + } + [Fact] public void GetInstalledCopilotAgents_ReturnsOnlyMauiRelatedAgents() { @@ -254,4 +307,17 @@ static bool TryCreateDirectorySymlink(string linkPath, string targetPath) return false; } } + + static bool TryCreateFileSymlink(string linkPath, string targetPath) + { + try + { + File.CreateSymbolicLink(linkPath, targetPath); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return false; + } + } } diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs b/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs index cf0228fd6..1f765d5ac 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs @@ -476,7 +476,7 @@ static bool IsValidRepoSegment(string segment) { try { - using var response = await http.GetAsync(url, ct).ConfigureAwait(false); + using var response = await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); // Return null only for 404; other HTTP errors propagate to callers. if (response.StatusCode == System.Net.HttpStatusCode.NotFound) @@ -497,7 +497,7 @@ static bool IsValidRepoSegment(string segment) { try { - using var response = await http.GetAsync(url, ct).ConfigureAwait(false); + using var response = await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); // Return null only for 404; other HTTP errors propagate to callers. if (response.StatusCode == System.Net.HttpStatusCode.NotFound) diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/RepositoryAssetInstaller.cs b/src/Cli/Microsoft.Maui.Cli/Ai/RepositoryAssetInstaller.cs index fa0bb09a6..051822743 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/RepositoryAssetInstaller.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/RepositoryAssetInstaller.cs @@ -84,6 +84,7 @@ public static async Task> GetCopilotAgentsAsync( var destinationBase = Path.GetFullPath(destinationRoot) + Path.DirectorySeparatorChar; var count = 0; + var downloadFailures = 0; foreach (var filePath in asset.Files) { var destinationPath = GetAssetFilePath(projectRoot, asset, filePath); @@ -91,12 +92,21 @@ public static async Task> GetCopilotAgentsAsync( if (!fullDestinationPath.StartsWith(destinationBase, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)) continue; - if (File.Exists(fullDestinationPath) && !force) - continue; + if (IsReparsePoint(fullDestinationPath)) + return (-1, string.Empty); + + if (File.Exists(fullDestinationPath)) + { + if (!force) + continue; + } var content = await MarketplaceClient.FetchRawBytesAsync(http, repo, branch, filePath, ct).ConfigureAwait(false); if (content is null) + { + downloadFailures++; continue; + } var destinationDirectory = Path.GetDirectoryName(fullDestinationPath) ?? destinationRoot; if (!FileSystemPathGuard.IsPathWithinRoot(destinationDirectory, projectRoot)) @@ -106,11 +116,14 @@ public static async Task> GetCopilotAgentsAsync( if (!FileSystemPathGuard.IsPathWithinRoot(destinationDirectory, projectRoot)) return (-1, string.Empty); - await File.WriteAllBytesAsync(fullDestinationPath, content, ct).ConfigureAwait(false); + if (IsReparsePoint(fullDestinationPath)) + return (-1, string.Empty); + + await WriteFileAtomicallyAsync(fullDestinationPath, content, ct).ConfigureAwait(false); count++; } - return (count, destinationRoot); + return downloadFailures > 0 ? (-2, destinationRoot) : (count, destinationRoot); } /// @@ -183,4 +196,35 @@ internal static string GetRemoteFileName(string path) var slashIndex = normalized.LastIndexOf('/'); return slashIndex >= 0 ? normalized[(slashIndex + 1)..] : normalized; } + + static async Task WriteFileAtomicallyAsync(string destinationPath, byte[] content, CancellationToken ct) + { + var destinationDirectory = Path.GetDirectoryName(destinationPath)!; + var tempPath = Path.Combine( + destinationDirectory, + $".{Path.GetFileName(destinationPath)}.{Guid.NewGuid():N}.tmp"); + + try + { + await File.WriteAllBytesAsync(tempPath, content, ct).ConfigureAwait(false); + File.Move(tempPath, destinationPath, overwrite: true); + } + finally + { + if (File.Exists(tempPath)) + File.Delete(tempPath); + } + } + + static bool IsReparsePoint(string path) + { + try + { + return (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0; + } + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) + { + return false; + } + } } diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs index f3eec5e13..327effa38 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs @@ -260,8 +260,10 @@ static Command CreateInitCommand() assetResults.Add((asset.Name, asset.Category, filesInstalled, installPath)); if (filesInstalled > 0) formatter.WriteSuccess($"Installed {asset.Name} → {asset.Category} ({filesInstalled} files)"); - else if (filesInstalled < 0) + else if (filesInstalled == -1) formatter.WriteWarning($"Skipped {asset.Name} → {asset.Category} (unsafe install path)"); + else if (filesInstalled == -2) + formatter.WriteWarning($"Failed to download asset files for '{asset.Name}'. Check your network connection."); else formatter.WriteInfo($"Skipped {asset.Name} → {asset.Category} (already installed)"); } diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs index 4ca572541..12bf74ac8 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs @@ -98,6 +98,7 @@ static Command CreateUpdateCommand() // so environments sharing the same skills directory are not updated twice. var updatable = new List<(DetectedEnvironment Env, string SkillDir, string SkillName, InstalledSkillVersion Version)>(); var processedPaths = new HashSet(FileSystemPathComparer); + var installedSkillFilterMatches = new HashSet(StringComparer.OrdinalIgnoreCase); var uncheckableCount = 0; foreach (var env in environments) @@ -115,9 +116,14 @@ static Command CreateUpdateCommand() if (IsDevFlowManagedSkillName(skillName)) continue; - if (filterSpecified && - !skillFilter!.Any(f => string.Equals(f, skillName, StringComparison.OrdinalIgnoreCase))) - continue; + if (filterSpecified) + { + var matchesFilter = skillFilter!.Any(f => string.Equals(f, skillName, StringComparison.OrdinalIgnoreCase)); + if (!matchesFilter) + continue; + + installedSkillFilterMatches.Add(skillName); + } var version = await SkillVersionStore.ReadAsync(skillDir, ct); if (version is null) @@ -148,6 +154,16 @@ version.Commit is null || } } + if (!HasUpdateFilterMatches( + filterSpecified, + devFlowTargets.Count, + selectedAgentAssets.Count, + installedSkillFilterMatches.Count)) + { + formatter.WriteWarning($"No skills or agents matched filter: {string.Join(", ", skillFilter!)}"); + return 1; + } + if (updatable.Count == 0 && devFlowTargetsToUpdate.Count == 0 && agentsToUpdate.Count == 0) { formatter.WriteSuccess(filterSpecified ? "All selected AI development assets are up to date." : "All AI development assets are up to date."); @@ -335,6 +351,13 @@ static string ShortCommit(string? commit) internal static bool HasUpdateInstallFailures(IEnumerable skillFileCounts, IEnumerable assetFileCounts) => skillFileCounts.Any(files => files <= 0) || assetFileCounts.Any(files => files <= 0); + internal static bool HasUpdateFilterMatches( + bool filterSpecified, + int devFlowTargetCount, + int selectedAgentAssetCount, + int installedSkillMatchCount) + => !filterSpecified || devFlowTargetCount > 0 || selectedAgentAssetCount > 0 || installedSkillMatchCount > 0; + internal static string GetUpdateStatus(bool hasUpdateFailures) => hasUpdateFailures ? "partial_failure" : "success"; } diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.cs index 840eb77d1..964a3ab26 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.cs @@ -14,6 +14,7 @@ public static partial class AiCommands private const string DefaultBranch = "main"; private const string RepositorySkillsRoot = ".github/skills"; private const string RepositorySkillsPluginName = "dotnet-maui-repo"; + internal static readonly TimeSpan GitHubHttpTimeout = TimeSpan.FromSeconds(30); private static readonly HashSet s_devFlowManagedSkills = new(StringComparer.OrdinalIgnoreCase) { @@ -63,9 +64,12 @@ static Option CreateForceOption() => /// Creates an configured for GitHub API access. /// Respects the GITHUB_TOKEN environment variable for authentication. /// - static HttpClient CreateGitHubHttpClient() + internal static HttpClient CreateGitHubHttpClient() { - var http = new HttpClient(); + var http = new HttpClient + { + Timeout = GitHubHttpTimeout + }; http.DefaultRequestHeaders.UserAgent.Add( new System.Net.Http.Headers.ProductInfoHeaderValue("Microsoft.Maui.Cli", "1.0")); http.DefaultRequestHeaders.Accept.Add( From a5d425eaae22ec0b064122d5c895de1a08a2ce53 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Thu, 21 May 2026 21:36:06 +0200 Subject: [PATCH 23/31] Tighten MAUI AI filesystem writes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MarketplaceClientTests.cs | 42 +++++++++++ .../McpConfiguratorTests.cs | 40 ++++++++++- .../RepositoryAssetInstallerTests.cs | 1 + .../Ai/FileSystemPathGuard.cs | 64 +++++++++++++++++ .../Ai/MarketplaceClient.cs | 20 +++--- .../Microsoft.Maui.Cli/Ai/McpConfigurator.cs | 2 +- .../Ai/RepositoryAssetInstaller.cs | 71 +++++-------------- 7 files changed, 174 insertions(+), 66 deletions(-) diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs index 6d29be9d6..94c5560f2 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs @@ -325,6 +325,35 @@ public async Task DownloadSkillFilesAsync_AllowsSafeRelativePath() Assert.Equal(1, handler.Calls); } + [Fact] + public async Task DownloadSkillFilesAsync_SymlinkedDestinationDirectoryOutsideRoot_SkipsWrite() + { + var outsideRoot = Path.Combine(_tempDir, "outside"); + var destinationRoot = Path.Combine(_tempDir, "destination"); + Directory.CreateDirectory(outsideRoot); + Directory.CreateDirectory(destinationRoot); + if (!TryCreateDirectorySymlink(Path.Combine(destinationRoot, "references"), outsideRoot)) + return; + + var handler = new FakeHttpMessageHandler(() => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent("safe content"u8.ToArray()) + }); + using var http = new HttpClient(handler); + var skill = new SkillInfo + { + Name = "good-skill", + RemotePath = "plugins/good", + Files = ["plugins/good/references/setup.md"] + }; + + var count = await MarketplaceClient.DownloadSkillFilesAsync( + http, skill, destinationRoot, "owner/repo", "main"); + + Assert.Equal(0, count); + Assert.False(File.Exists(Path.Combine(outsideRoot, "setup.md"))); + } + [Fact] public async Task DownloadSkillFilesAsync_RejectsFilesOutsideRemotePath() { @@ -542,4 +571,17 @@ protected override bool TryComputeLength(out long length) return true; } } + + private static bool TryCreateDirectorySymlink(string linkPath, string targetPath) + { + try + { + Directory.CreateSymbolicLink(linkPath, targetPath); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return false; + } + } } diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs index e5a8ce0aa..8c649ed18 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs @@ -140,7 +140,8 @@ await File.WriteAllTextAsync(configPath, """ var result = await McpConfigurator.ConfigureAsync(env); Assert.True(result); - var backupPath = Assert.Single(Directory.GetFiles(configDir, "mcp.json.*.bak")); + var backupPath = Path.Combine(configDir, "mcp.json.bak"); + Assert.True(File.Exists(backupPath)); var backup = await File.ReadAllTextAsync(backupPath); Assert.Contains("// Existing user MCP server.", backup); @@ -151,6 +152,43 @@ await File.WriteAllTextAsync(configPath, """ Assert.NotNull(servers["maui-devflow"]); } + [Fact] + public async Task ConfigureAsync_JsoncBackupUsesStablePath() + { + var configDir = Path.Combine(_tempDir, ".vscode"); + Directory.CreateDirectory(configDir); + var configPath = Path.Combine(configDir, "mcp.json"); + var env = new DetectedEnvironment + { + Kind = AgentEnvironmentKind.VsCode, + McpConfigPath = configPath, + SkillsDirectory = Path.Combine(_tempDir, ".github", "skills") + }; + + await File.WriteAllTextAsync(configPath, """ + { + // First backup. + "mcpServers": {} + } + """); + await McpConfigurator.ConfigureAsync(env); + + await File.WriteAllTextAsync(configPath, """ + { + // Second backup. + "mcpServers": { + "maui-devflow": { "command": "wrong" } + } + } + """); + await McpConfigurator.ConfigureAsync(env); + + var backupPath = Path.Combine(configDir, "mcp.json.bak"); + var backup = await File.ReadAllTextAsync(backupPath); + Assert.Contains("// Second backup.", backup); + Assert.Single(Directory.GetFiles(configDir, "mcp.json*.bak")); + } + [Fact] public async Task ConfigureAsync_Idempotent_DoesNotDuplicateEntry() { diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/RepositoryAssetInstallerTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/RepositoryAssetInstallerTests.cs index 86a9f63ce..7dd4379bf 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/RepositoryAssetInstallerTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/RepositoryAssetInstallerTests.cs @@ -273,6 +273,7 @@ public void GetInstalledCopilotAgents_IncludesNestedAgents() var asset = Assert.Single(assets); Assert.Equal("expert-reviewer", asset.Name); + Assert.Equal(Path.Combine(agentsDir, "expert-reviewer.agent.md"), Assert.Single(asset.Files)); } sealed class MapHttpMessageHandler(Dictionary responses) : HttpMessageHandler diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/FileSystemPathGuard.cs b/src/Cli/Microsoft.Maui.Cli/Ai/FileSystemPathGuard.cs index 9e775f21d..8eeb610af 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/FileSystemPathGuard.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/FileSystemPathGuard.cs @@ -45,6 +45,65 @@ internal static string ResolveCanonicalPath(string path) return Path.GetFullPath(current); } + internal static async Task WriteFileAtomicallyWithinRootAsync( + string destinationPath, + string root, + byte[] content, + CancellationToken ct) + { + var fullDestinationPath = Path.GetFullPath(destinationPath); + var destinationDirectory = Path.GetDirectoryName(fullDestinationPath); + if (destinationDirectory is null) + return false; + + if (!IsPathWithinRoot(destinationDirectory, root)) + return false; + + Directory.CreateDirectory(destinationDirectory); + if (!IsSafeDestination(fullDestinationPath, destinationDirectory, root)) + return false; + + var tempPath = Path.Combine( + destinationDirectory, + $".{Path.GetFileName(fullDestinationPath)}.{Guid.NewGuid():N}.tmp"); + + try + { + await File.WriteAllBytesAsync(tempPath, content, ct).ConfigureAwait(false); + if (!IsPathWithinRoot(tempPath, root) || + !IsSafeDestination(fullDestinationPath, destinationDirectory, root)) + { + return false; + } + + if (File.Exists(fullDestinationPath)) + File.Delete(fullDestinationPath); + + if (!IsSafeDestination(fullDestinationPath, destinationDirectory, root)) + return false; + + File.Move(tempPath, fullDestinationPath, overwrite: false); + return true; + } + finally + { + if (File.Exists(tempPath)) + File.Delete(tempPath); + } + } + + internal static bool IsReparsePoint(string path) + { + try + { + return (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0; + } + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) + { + return false; + } + } + static string ResolveExistingFileSystemEntry(string path) { FileSystemInfo? info = Directory.Exists(path) @@ -58,4 +117,9 @@ static string ResolveExistingFileSystemEntry(string path) return info.ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? info.FullName; } + + static bool IsSafeDestination(string destinationPath, string destinationDirectory, string root) + => IsPathWithinRoot(destinationDirectory, root) && + IsPathWithinRoot(destinationPath, root) && + !IsReparsePoint(destinationPath); } diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs b/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs index 1f765d5ac..61fe5521f 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs @@ -172,7 +172,6 @@ public static async Task DownloadSkillFilesAsync( HttpClient http, SkillInfo skill, string destDir, string repo, string branch, CancellationToken ct = default) { var count = 0; - var fullBase = Path.GetFullPath(destDir) + Path.DirectorySeparatorChar; foreach (var filePath in skill.Files) { @@ -195,20 +194,22 @@ public static async Task DownloadSkillFilesAsync( var destPath = Path.Combine(destDir, relativePath.Replace('/', Path.DirectorySeparatorChar)); - // Validate the resolved path stays under the destination directory. - var fullDest = Path.GetFullPath(destPath); - if (!fullDest.StartsWith(fullBase, PathComparison)) + if (!FileSystemPathGuard.IsPathWithinRoot(destPath, destDir)) continue; var content = await FetchRawBytesAsync(http, repo, branch, normalizedFilePath, ct).ConfigureAwait(false); if (content is null) continue; - var destFileDir = Path.GetDirectoryName(destPath); - if (destFileDir is not null) - Directory.CreateDirectory(destFileDir); + if (!await FileSystemPathGuard.WriteFileAtomicallyWithinRootAsync( + destPath, + destDir, + content, + ct).ConfigureAwait(false)) + { + continue; + } - await File.WriteAllBytesAsync(destPath, content, ct).ConfigureAwait(false); count++; } @@ -469,9 +470,6 @@ static bool IsValidRepoSegment(string segment) return true; } - static StringComparison PathComparison => - OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; - private static async Task FetchStringAsync(HttpClient http, string url, CancellationToken ct = default) { try diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs b/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs index e8a964984..74379e58f 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs @@ -204,7 +204,7 @@ static async Task WriteBackupAsync(string configPath, string contents, Cancellat } static string GetBackupPath(string configPath) - => $"{configPath}.{Guid.NewGuid():N}.bak"; + => $"{configPath}.bak"; static bool ContainsJsonComments(string contents) { diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/RepositoryAssetInstaller.cs b/src/Cli/Microsoft.Maui.Cli/Ai/RepositoryAssetInstaller.cs index 051822743..65efc832a 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/RepositoryAssetInstaller.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/RepositoryAssetInstaller.cs @@ -81,45 +81,40 @@ public static async Task> GetCopilotAgentsAsync( if (!FileSystemPathGuard.IsPathWithinRoot(destinationRoot, projectRoot)) return (-1, string.Empty); - var destinationBase = Path.GetFullPath(destinationRoot) + Path.DirectorySeparatorChar; - var count = 0; var downloadFailures = 0; foreach (var filePath in asset.Files) { - var destinationPath = GetAssetFilePath(projectRoot, asset, filePath); - var fullDestinationPath = Path.GetFullPath(destinationPath); - if (!fullDestinationPath.StartsWith(destinationBase, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)) + var content = await MarketplaceClient.FetchRawBytesAsync(http, repo, branch, filePath, ct).ConfigureAwait(false); + if (content is null) + { + downloadFailures++; continue; + } - if (IsReparsePoint(fullDestinationPath)) + var destinationPath = GetAssetFilePath(projectRoot, asset, filePath); + var fullDestinationPath = Path.GetFullPath(destinationPath); + if (FileSystemPathGuard.IsReparsePoint(fullDestinationPath)) return (-1, string.Empty); + if (!FileSystemPathGuard.IsPathWithinRoot(fullDestinationPath, destinationRoot)) + continue; + if (File.Exists(fullDestinationPath)) { if (!force) continue; } - var content = await MarketplaceClient.FetchRawBytesAsync(http, repo, branch, filePath, ct).ConfigureAwait(false); - if (content is null) + if (!await FileSystemPathGuard.WriteFileAtomicallyWithinRootAsync( + fullDestinationPath, + projectRoot, + content, + ct).ConfigureAwait(false)) { - downloadFailures++; - continue; - } - - var destinationDirectory = Path.GetDirectoryName(fullDestinationPath) ?? destinationRoot; - if (!FileSystemPathGuard.IsPathWithinRoot(destinationDirectory, projectRoot)) - return (-1, string.Empty); - - Directory.CreateDirectory(destinationDirectory); - if (!FileSystemPathGuard.IsPathWithinRoot(destinationDirectory, projectRoot)) - return (-1, string.Empty); - - if (IsReparsePoint(fullDestinationPath)) return (-1, string.Empty); + } - await WriteFileAtomicallyAsync(fullDestinationPath, content, ct).ConfigureAwait(false); count++; } @@ -153,7 +148,7 @@ public static List GetInstalledCopilotAgents(string project Description = description, RemotePath = string.Empty, DestinationRoot = CopilotAgentsDestinationRoot, - Files = [Path.Combine(destinationRoot, fileName)] + Files = [filePath] }); } @@ -197,34 +192,4 @@ internal static string GetRemoteFileName(string path) return slashIndex >= 0 ? normalized[(slashIndex + 1)..] : normalized; } - static async Task WriteFileAtomicallyAsync(string destinationPath, byte[] content, CancellationToken ct) - { - var destinationDirectory = Path.GetDirectoryName(destinationPath)!; - var tempPath = Path.Combine( - destinationDirectory, - $".{Path.GetFileName(destinationPath)}.{Guid.NewGuid():N}.tmp"); - - try - { - await File.WriteAllBytesAsync(tempPath, content, ct).ConfigureAwait(false); - File.Move(tempPath, destinationPath, overwrite: true); - } - finally - { - if (File.Exists(tempPath)) - File.Delete(tempPath); - } - } - - static bool IsReparsePoint(string path) - { - try - { - return (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0; - } - catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) - { - return false; - } - } } From e76be6223f9420d68df2b9508648413337099fb2 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Thu, 21 May 2026 21:59:13 +0200 Subject: [PATCH 24/31] Stabilize MAUI AI asset update edge cases Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AiCommandsTests.cs | 18 ++++++++- .../RepositoryAssetInstallerTests.cs | 28 +++++++++++++ .../Ai/FileSystemPathGuard.cs | 8 +--- .../Ai/RepositoryAssetInstaller.cs | 39 +++++++++++++------ .../Commands/AiCommands.AssetStatus.cs | 38 +++++++++++++++--- .../Commands/AiCommands.Update.cs | 6 +-- 6 files changed, 109 insertions(+), 28 deletions(-) diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs index 6b5d1d4a0..c6ee1d108 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs @@ -348,12 +348,28 @@ public void HasInitInstallFailures_DetectsNegativeFileCounts(int[] skillFileCoun [InlineData(new[] { -2, 1 }, new[] { 1 }, true)] [InlineData(new[] { 0, 1 }, new[] { 1 }, true)] [InlineData(new[] { 1 }, new[] { -1 }, true)] - [InlineData(new[] { 1 }, new[] { 0 }, true)] + [InlineData(new[] { 1 }, new[] { 0 }, false)] public void HasUpdateInstallFailures_DetectsFailedUpdates(int[] skillFileCounts, int[] assetFileCounts, bool expected) { Assert.Equal(expected, AiCommands.HasUpdateInstallFailures(skillFileCounts, assetFileCounts)); } + [Fact] + public async Task TryGetRemoteCommitShaAsync_InvalidPath_ReturnsUncheckable() + { + using var http = new HttpClient(); + + var result = await AiCommands.TryGetRemoteCommitShaAsync( + http, + "owner/repo", + "main", + "../bad-path", + CancellationToken.None); + + Assert.False(result.IsCheckable); + Assert.Null(result.RemoteSha); + } + [Theory] [InlineData(false, 0, 0, 0, true)] [InlineData(true, 1, 0, 0, true)] diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/RepositoryAssetInstallerTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/RepositoryAssetInstallerTests.cs index 7dd4379bf..3c2d361b7 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/RepositoryAssetInstallerTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/RepositoryAssetInstallerTests.cs @@ -172,6 +172,34 @@ public async Task InstallAssetAsync_DownloadFailure_ReturnsNegativeTwo() Assert.False(File.Exists(Path.Combine(_tempDir, ".github", "agents", "expert-reviewer.agent.md"))); } + [Fact] + public async Task InstallAssetAsync_PartialDownloadFailureWritesNoFiles() + { + using var http = new HttpClient(new MapHttpMessageHandler(new Dictionary + { + ["ios/expert-reviewer.agent.md"] = "ios agent" + })); + var asset = new RepositoryAssetInfo + { + Name = "expert-reviewer", + Category = "agent", + RemotePath = ".github/agents/ios/expert-reviewer.agent.md", + DestinationRoot = ".github/agents", + Files = + [ + ".github/agents/ios/expert-reviewer.agent.md", + ".github/agents/android/expert-reviewer.agent.md" + ] + }; + + var result = await RepositoryAssetInstaller.InstallAssetAsync( + http, asset, _tempDir, "owner/repo", "main", force: true); + + Assert.Equal(-2, result.FilesInstalled); + Assert.False(File.Exists(Path.Combine(_tempDir, ".github", "agents", "ios", "expert-reviewer.agent.md"))); + Assert.False(File.Exists(Path.Combine(_tempDir, ".github", "agents", "android", "expert-reviewer.agent.md"))); + } + [Fact] public void GetInstalledCopilotAgents_ReturnsOnlyMauiRelatedAgents() { diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/FileSystemPathGuard.cs b/src/Cli/Microsoft.Maui.Cli/Ai/FileSystemPathGuard.cs index 8eeb610af..bae57a9c3 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/FileSystemPathGuard.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/FileSystemPathGuard.cs @@ -76,13 +76,7 @@ internal static async Task WriteFileAtomicallyWithinRootAsync( return false; } - if (File.Exists(fullDestinationPath)) - File.Delete(fullDestinationPath); - - if (!IsSafeDestination(fullDestinationPath, destinationDirectory, root)) - return false; - - File.Move(tempPath, fullDestinationPath, overwrite: false); + File.Move(tempPath, fullDestinationPath, overwrite: true); return true; } finally diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/RepositoryAssetInstaller.cs b/src/Cli/Microsoft.Maui.Cli/Ai/RepositoryAssetInstaller.cs index 65efc832a..df4cead43 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/RepositoryAssetInstaller.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/RepositoryAssetInstaller.cs @@ -81,17 +81,9 @@ public static async Task> GetCopilotAgentsAsync( if (!FileSystemPathGuard.IsPathWithinRoot(destinationRoot, projectRoot)) return (-1, string.Empty); - var count = 0; - var downloadFailures = 0; + var filesToInstall = new List<(string RemotePath, string DestinationPath)>(); foreach (var filePath in asset.Files) { - var content = await MarketplaceClient.FetchRawBytesAsync(http, repo, branch, filePath, ct).ConfigureAwait(false); - if (content is null) - { - downloadFailures++; - continue; - } - var destinationPath = GetAssetFilePath(projectRoot, asset, filePath); var fullDestinationPath = Path.GetFullPath(destinationPath); if (FileSystemPathGuard.IsReparsePoint(fullDestinationPath)) @@ -106,8 +98,33 @@ public static async Task> GetCopilotAgentsAsync( continue; } + filesToInstall.Add((filePath, fullDestinationPath)); + } + + var fileContents = new List<(string DestinationPath, byte[] Content)>(); + foreach (var (remotePath, destinationPath) in filesToInstall) + { + var content = await MarketplaceClient.FetchRawBytesAsync(http, repo, branch, remotePath, ct).ConfigureAwait(false); + if (content is null) + return (-2, destinationRoot); + + fileContents.Add((destinationPath, content)); + } + + foreach (var (destinationPath, _) in fileContents) + { + if (FileSystemPathGuard.IsReparsePoint(destinationPath) || + !FileSystemPathGuard.IsPathWithinRoot(destinationPath, destinationRoot)) + { + return (-1, string.Empty); + } + } + + var count = 0; + foreach (var (destinationPath, content) in fileContents) + { if (!await FileSystemPathGuard.WriteFileAtomicallyWithinRootAsync( - fullDestinationPath, + destinationPath, projectRoot, content, ct).ConfigureAwait(false)) @@ -118,7 +135,7 @@ public static async Task> GetCopilotAgentsAsync( count++; } - return downloadFailures > 0 ? (-2, destinationRoot) : (count, destinationRoot); + return (count, destinationRoot); } /// diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.AssetStatus.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.AssetStatus.cs index 4446c5edc..c2485fb07 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.AssetStatus.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.AssetStatus.cs @@ -89,14 +89,21 @@ static async Task> GetMarketplaceSkillStatusRowsAsync( if (checkUpdates && http is not null && version.PluginPath is not null) { - var remoteSha = await MarketplaceClient.GetRemoteCommitShaAsync( + var (isCheckable, remoteSha) = await TryGetRemoteCommitShaAsync( http, repo, branch, version.PluginPath, ct).ConfigureAwait(false); - status = remoteSha is not null && version.Commit is not null - ? string.Equals(remoteSha, version.Commit, StringComparison.OrdinalIgnoreCase) - ? "Up to date" - : "Update available" - : "Unknown"; + if (!isCheckable) + { + status = "Error"; + } + else + { + status = remoteSha is not null && version.Commit is not null + ? string.Equals(remoteSha, version.Commit, StringComparison.OrdinalIgnoreCase) + ? "Up to date" + : "Update available" + : "Unknown"; + } } rows.Add(new AiAssetStatusRow(skillName, "Skill", env.Kind.ToString(), installed, status, skillDir)); @@ -106,6 +113,25 @@ static async Task> GetMarketplaceSkillStatusRowsAsync( return rows; } + internal static async Task<(bool IsCheckable, string? RemoteSha)> TryGetRemoteCommitShaAsync( + HttpClient http, + string repo, + string branch, + string path, + CancellationToken ct) + { + try + { + var remoteSha = await MarketplaceClient.GetRemoteCommitShaAsync( + http, repo, branch, path, ct).ConfigureAwait(false); + return (true, remoteSha); + } + catch (InvalidOperationException) + { + return (false, null); + } + } + internal static List GetInstalledAgentStatusRows(string workingDir) => RepositoryAssetInstaller.GetInstalledCopilotAgents(workingDir) .Select(asset => new AiAssetStatusRow( diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs index 12bf74ac8..d99809033 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs @@ -132,10 +132,10 @@ static Command CreateUpdateCommand() // Check if update is available if (version.PluginPath is not null) { - var remoteSha = await MarketplaceClient.GetRemoteCommitShaAsync( + var (isCheckable, remoteSha) = await TryGetRemoteCommitShaAsync( http, repo, branch, version.PluginPath, ct); - if (remoteSha is null) + if (!isCheckable || remoteSha is null) { uncheckableCount++; if (force) @@ -349,7 +349,7 @@ static string ShortCommit(string? commit) OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; internal static bool HasUpdateInstallFailures(IEnumerable skillFileCounts, IEnumerable assetFileCounts) - => skillFileCounts.Any(files => files <= 0) || assetFileCounts.Any(files => files <= 0); + => skillFileCounts.Any(files => files <= 0) || assetFileCounts.Any(files => files < 0); internal static bool HasUpdateFilterMatches( bool filterSpecified, From cb471bac9acf6b9d5bd595d3ec568f52512214dc Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Thu, 21 May 2026 22:20:54 +0200 Subject: [PATCH 25/31] Harden MAUI AI local asset handling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../McpConfiguratorTests.cs | 43 ++++++++++++++++++ .../RepositoryAssetInstallerTests.cs | 43 ++++++++++++++++++ .../Ai/AgentEnvironmentDetector.cs | 3 +- .../Microsoft.Maui.Cli/Ai/McpConfigurator.cs | 9 +++- .../Ai/RepositoryAssetInstaller.cs | 45 +++++++++++++++++-- 5 files changed, 136 insertions(+), 7 deletions(-) diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs index 8c649ed18..8e1aaad66 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs @@ -189,6 +189,36 @@ await File.WriteAllTextAsync(configPath, """ Assert.Single(Directory.GetFiles(configDir, "mcp.json*.bak")); } + [Fact] + public async Task ConfigureAsync_SymlinkedBackupPath_DoesNotOverwriteTarget() + { + var configDir = Path.Combine(_tempDir, ".vscode"); + Directory.CreateDirectory(configDir); + var configPath = Path.Combine(configDir, "mcp.json"); + var outsideFile = Path.Combine(_tempDir, "outside.txt"); + await File.WriteAllTextAsync(outsideFile, "outside content"); + if (!TryCreateFileSymlink(Path.Combine(configDir, "mcp.json.bak"), outsideFile)) + return; + + await File.WriteAllTextAsync(configPath, """ + { + // Existing user MCP server. + "mcpServers": {} + } + """); + var env = new DetectedEnvironment + { + Kind = AgentEnvironmentKind.VsCode, + McpConfigPath = configPath, + SkillsDirectory = Path.Combine(_tempDir, ".github", "skills") + }; + + var result = await McpConfigurator.ConfigureAsync(env); + + Assert.True(result); + Assert.Equal("outside content", await File.ReadAllTextAsync(outsideFile)); + } + [Fact] public async Task ConfigureAsync_Idempotent_DoesNotDuplicateEntry() { @@ -491,4 +521,17 @@ static bool TryCreateDirectorySymlink(string linkPath, string targetPath) return false; } } + + static bool TryCreateFileSymlink(string linkPath, string targetPath) + { + try + { + File.CreateSymbolicLink(linkPath, targetPath); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return false; + } + } } diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/RepositoryAssetInstallerTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/RepositoryAssetInstallerTests.cs index 3c2d361b7..305bec786 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/RepositoryAssetInstallerTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/RepositoryAssetInstallerTests.cs @@ -229,6 +229,49 @@ This agent does not cover maui-specific logic. Assert.Equal(".github/agents", asset.DestinationRoot); } + [Fact] + public void GetInstalledCopilotAgents_SkipsOversizedAgentFiles() + { + var agentsDir = Path.Combine(_tempDir, ".github", "agents"); + Directory.CreateDirectory(agentsDir); + File.WriteAllText(Path.Combine(agentsDir, "expert-reviewer.agent.md"), """ + --- + name: expert-reviewer + description: Expert .NET MAUI DevFlow code reviewer. + --- + # Expert Reviewer + """); + File.WriteAllText( + Path.Combine(agentsDir, "maui-oversized.agent.md"), + new string('x', 1024 * 1024 + 1)); + + var assets = RepositoryAssetInstaller.GetInstalledCopilotAgents(_tempDir); + + var asset = Assert.Single(assets); + Assert.Equal("expert-reviewer", asset.Name); + } + + [Fact] + public void GetInstalledCopilotAgents_SkipsSymlinkedAgentFiles() + { + var agentsDir = Path.Combine(_tempDir, ".github", "agents"); + Directory.CreateDirectory(agentsDir); + var outsideFile = Path.Combine(_tempDir, "outside-agent.agent.md"); + File.WriteAllText(outsideFile, """ + --- + name: symlinked-maui-agent + description: .NET MAUI symlinked agent. + --- + # Symlinked + """); + if (!TryCreateFileSymlink(Path.Combine(agentsDir, "symlinked-maui-agent.agent.md"), outsideFile)) + return; + + var assets = RepositoryAssetInstaller.GetInstalledCopilotAgents(_tempDir); + + Assert.Empty(assets); + } + [Fact] public async Task InstallAssetAsync_ForceOverwritesExistingAgent() { diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/AgentEnvironmentDetector.cs b/src/Cli/Microsoft.Maui.Cli/Ai/AgentEnvironmentDetector.cs index 5cb325620..0f46b37b1 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/AgentEnvironmentDetector.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/AgentEnvironmentDetector.cs @@ -75,8 +75,7 @@ public static List Detect(string workingDir) if (rootFullPath is null) break; - if (rootFullPath is not null && - string.Equals(current.FullName, rootFullPath, PathComparison)) + if (string.Equals(current.FullName, rootFullPath, PathComparison)) break; current = current.Parent; diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs b/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs index 74379e58f..db2eb6097 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs @@ -200,7 +200,14 @@ static async Task WriteAtomicAsync(string configPath, string contents, Cancellat static async Task WriteBackupAsync(string configPath, string contents, CancellationToken ct) { - await File.WriteAllTextAsync(GetBackupPath(configPath), contents, ct).ConfigureAwait(false); + var backupPath = GetBackupPath(configPath); + var backupDir = Path.GetDirectoryName(backupPath); + var backupRoot = string.IsNullOrEmpty(backupDir) ? Directory.GetCurrentDirectory() : backupDir; + await FileSystemPathGuard.WriteFileAtomicallyWithinRootAsync( + backupPath, + backupRoot, + Encoding.UTF8.GetBytes(contents), + ct).ConfigureAwait(false); } static string GetBackupPath(string configPath) diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/RepositoryAssetInstaller.cs b/src/Cli/Microsoft.Maui.Cli/Ai/RepositoryAssetInstaller.cs index df4cead43..e863b87e2 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/RepositoryAssetInstaller.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/RepositoryAssetInstaller.cs @@ -12,6 +12,7 @@ internal static class RepositoryAssetInstaller { internal const string CopilotAgentsRoot = ".github/agents"; internal const string CopilotAgentsDestinationRoot = ".github/agents"; + const long MaxLocalAgentBytes = 1024 * 1024; /// /// Discovers MAUI-related Copilot agent definitions from .github/agents. @@ -74,11 +75,13 @@ public static async Task> GetCopilotAgentsAsync( CancellationToken ct = default) { var destinationRoot = GetDestinationRoot(projectRoot, asset.DestinationRoot); - if (!FileSystemPathGuard.IsPathWithinRoot(destinationRoot, projectRoot)) + if (FileSystemPathGuard.IsReparsePoint(destinationRoot) || + !FileSystemPathGuard.IsPathWithinRoot(destinationRoot, projectRoot)) return (-1, string.Empty); Directory.CreateDirectory(destinationRoot); - if (!FileSystemPathGuard.IsPathWithinRoot(destinationRoot, projectRoot)) + if (FileSystemPathGuard.IsReparsePoint(destinationRoot) || + !FileSystemPathGuard.IsPathWithinRoot(destinationRoot, projectRoot)) return (-1, string.Empty); var filesToInstall = new List<(string RemotePath, string DestinationPath)>(); @@ -148,10 +151,19 @@ public static List GetInstalledCopilotAgents(string project if (!Directory.Exists(destinationRoot)) return assets; - foreach (var filePath in Directory.GetFiles(destinationRoot, "*.agent.md", SearchOption.AllDirectories) + var options = new EnumerationOptions + { + RecurseSubdirectories = true, + IgnoreInaccessible = true, + AttributesToSkip = FileAttributes.ReparsePoint + }; + + foreach (var filePath in Directory.GetFiles(destinationRoot, "*.agent.md", options) .OrderBy(path => path, StringComparer.Ordinal)) { - var content = File.ReadAllText(filePath); + if (!TryReadAgentFile(filePath, out var content)) + continue; + var (name, description) = MarketplaceClient.ParseFrontmatter(content); var fileName = Path.GetFileName(filePath); var assetName = name ?? fileName[..^".agent.md".Length]; @@ -172,6 +184,31 @@ public static List GetInstalledCopilotAgents(string project return assets; } + static bool TryReadAgentFile(string filePath, out string content) + { + content = string.Empty; + try + { + if (FileSystemPathGuard.IsReparsePoint(filePath)) + return false; + + var info = new FileInfo(filePath); + if (!info.Exists || info.Length > MaxLocalAgentBytes) + return false; + + content = File.ReadAllText(filePath); + return true; + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + } + internal static string GetAssetFilePath(string projectRoot, RepositoryAssetInfo asset, string filePath) { var destinationRoot = GetDestinationRoot(projectRoot, asset.DestinationRoot); From 4046de71d50bfca758e003cfa864a2a743b02149 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Thu, 21 May 2026 22:43:51 +0200 Subject: [PATCH 26/31] Harden MAUI AI status and metadata writes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AiCommandsTests.cs | 47 ++++++++++++++++++- .../MarketplaceClientTests.cs | 24 ++++++++++ .../SkillVersionStoreTests.cs | 16 +++++++ .../Ai/FileSystemPathGuard.cs | 13 +++-- .../Ai/MarketplaceClient.cs | 4 +- .../Microsoft.Maui.Cli/Ai/McpConfigurator.cs | 12 +++-- .../Ai/SkillVersionStore.cs | 9 +++- .../Commands/AiCommands.AssetStatus.cs | 33 ++++++++++++- .../Commands/AiCommands.Update.cs | 2 +- 9 files changed, 145 insertions(+), 15 deletions(-) diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs index c6ee1d108..cef823d05 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs @@ -346,7 +346,7 @@ public void HasInitInstallFailures_DetectsNegativeFileCounts(int[] skillFileCoun [Theory] [InlineData(new[] { 1, 2 }, new[] { 1 }, false)] [InlineData(new[] { -2, 1 }, new[] { 1 }, true)] - [InlineData(new[] { 0, 1 }, new[] { 1 }, true)] + [InlineData(new[] { 0, 1 }, new[] { 1 }, false)] [InlineData(new[] { 1 }, new[] { -1 }, true)] [InlineData(new[] { 1 }, new[] { 0 }, false)] public void HasUpdateInstallFailures_DetectsFailedUpdates(int[] skillFileCounts, int[] assetFileCounts, bool expected) @@ -370,6 +370,42 @@ public async Task TryGetRemoteCommitShaAsync_InvalidPath_ReturnsUncheckable() Assert.Null(result.RemoteSha); } + [Fact] + public async Task GetRemoteAgentStatusRowsAsync_OversizedLocalAgent_ReturnsUnknown() + { + var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + try + { + var localAgentPath = Path.Combine(tempDir, ".github", "agents", "expert-reviewer.agent.md"); + Directory.CreateDirectory(Path.GetDirectoryName(localAgentPath)!); + await File.WriteAllTextAsync(localAgentPath, new string('x', 1024 * 1024 + 1)); + using var http = new HttpClient(new StaticContentHandler("remote content")); + var asset = new RepositoryAssetInfo + { + Name = "expert-reviewer", + Category = "agent", + DestinationRoot = ".github/agents", + Files = [".github/agents/expert-reviewer.agent.md"] + }; + + var rows = await AiCommands.GetRemoteAgentStatusRowsAsync( + http, + [asset], + tempDir, + "owner/repo", + "main", + CancellationToken.None); + + var row = Assert.Single(rows); + Assert.Equal("Unknown", row.Row.Status); + } + finally + { + if (Directory.Exists(tempDir)) + Directory.Delete(tempDir, recursive: true); + } + } + [Theory] [InlineData(false, 0, 0, 0, true)] [InlineData(true, 1, 0, 0, true)] @@ -429,4 +465,13 @@ private static void AssertNoWhitespaceAliases(Command command) AssertNoWhitespaceAliases(subcommand); } } + + sealed class StaticContentHandler(string content) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new ByteArrayContent(System.Text.Encoding.UTF8.GetBytes(content)) + }); + } } diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs index 94c5560f2..a5f36ef41 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs @@ -526,6 +526,24 @@ public async Task FetchRawBytesAsync_OversizedResponse_ReturnsNull() Assert.Null(result); } + [Fact] + public async Task FetchRawStringAsync_Timeout_ThrowsHttpRequestException() + { + using var http = new HttpClient(new TimeoutHttpMessageHandler()); + + await Assert.ThrowsAsync(() => + MarketplaceClient.FetchRawStringAsync(http, "owner/repo", "main", "README.md")); + } + + [Fact] + public async Task FetchRawBytesAsync_Timeout_ThrowsHttpRequestException() + { + using var http = new HttpClient(new TimeoutHttpMessageHandler()); + + await Assert.ThrowsAsync(() => + MarketplaceClient.FetchRawBytesAsync(http, "owner/repo", "main", "README.md")); + } + /// /// Minimal HttpMessageHandler that returns a fresh response for every request. /// @@ -572,6 +590,12 @@ protected override bool TryComputeLength(out long length) } } + private sealed class TimeoutHttpMessageHandler : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => throw new TaskCanceledException("Simulated timeout"); + } + private static bool TryCreateDirectorySymlink(string linkPath, string targetPath) { try diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillVersionStoreTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillVersionStoreTests.cs index 607e420f4..b4fd68e7a 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillVersionStoreTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillVersionStoreTests.cs @@ -169,6 +169,22 @@ public async Task WriteAsync_ProducesIndentedJson() Assert.Contains(" ", content); } + [Fact] + public async Task WriteAsync_RemovesTemporaryWriteDirectory() + { + var skillDir = Path.Combine(_tempDir, "atomic-skill"); + var version = new InstalledSkillVersion + { + Name = "test-skill", + Commit = "abc123" + }; + + await SkillVersionStore.WriteAsync(skillDir, version); + + Assert.Empty(Directory.GetDirectories(skillDir, ".maui-ai-write.*.tmp")); + Assert.True(File.Exists(Path.Combine(skillDir, ".skill-version"))); + } + [Fact] public async Task WriteAsync_NullProperties_AreOmitted() { diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/FileSystemPathGuard.cs b/src/Cli/Microsoft.Maui.Cli/Ai/FileSystemPathGuard.cs index bae57a9c3..d20906a69 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/FileSystemPathGuard.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/FileSystemPathGuard.cs @@ -63,12 +63,15 @@ internal static async Task WriteFileAtomicallyWithinRootAsync( if (!IsSafeDestination(fullDestinationPath, destinationDirectory, root)) return false; - var tempPath = Path.Combine( - destinationDirectory, - $".{Path.GetFileName(fullDestinationPath)}.{Guid.NewGuid():N}.tmp"); + var tempDirectory = Path.Combine(root, $".maui-ai-write.{Guid.NewGuid():N}.tmp"); + var tempPath = Path.Combine(tempDirectory, Path.GetFileName(fullDestinationPath)); try { + Directory.CreateDirectory(tempDirectory); + if (IsReparsePoint(tempDirectory) || !IsPathWithinRoot(tempDirectory, root)) + return false; + await File.WriteAllBytesAsync(tempPath, content, ct).ConfigureAwait(false); if (!IsPathWithinRoot(tempPath, root) || !IsSafeDestination(fullDestinationPath, destinationDirectory, root)) @@ -81,8 +84,8 @@ internal static async Task WriteFileAtomicallyWithinRootAsync( } finally { - if (File.Exists(tempPath)) - File.Delete(tempPath); + if (Directory.Exists(tempDirectory)) + Directory.Delete(tempDirectory, recursive: true); } } diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs b/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs index 61fe5521f..e1da4a1f9 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs @@ -487,7 +487,7 @@ static bool IsValidRepoSegment(string segment) } catch (TaskCanceledException) when (!ct.IsCancellationRequested) { - return null; // real HTTP timeout + throw new HttpRequestException("GitHub request timed out.", null, System.Net.HttpStatusCode.RequestTimeout); } } @@ -507,7 +507,7 @@ static bool IsValidRepoSegment(string segment) } catch (TaskCanceledException) when (!ct.IsCancellationRequested) { - return null; // real HTTP timeout + throw new HttpRequestException("GitHub request timed out.", null, System.Net.HttpStatusCode.RequestTimeout); } } diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs b/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs index db2eb6097..7a6456a88 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs @@ -91,7 +91,7 @@ public static async Task ConfigureAsync(DetectedEnvironment env, string? p var options = new JsonSerializerOptions { WriteIndented = true }; if (backupExistingConfig && existingJson is not null) - await WriteBackupAsync(configPath, existingJson, ct).ConfigureAwait(false); + await WriteBackupAsync(configPath, existingJson, projectRoot, ct).ConfigureAwait(false); await WriteAtomicAsync(configPath, root.ToJsonString(options), ct).ConfigureAwait(false); @@ -198,11 +198,15 @@ static async Task WriteAtomicAsync(string configPath, string contents, Cancellat } } - static async Task WriteBackupAsync(string configPath, string contents, CancellationToken ct) + static async Task WriteBackupAsync(string configPath, string contents, string? projectRoot, CancellationToken ct) { var backupPath = GetBackupPath(configPath); - var backupDir = Path.GetDirectoryName(backupPath); - var backupRoot = string.IsNullOrEmpty(backupDir) ? Directory.GetCurrentDirectory() : backupDir; + var configDir = Path.GetDirectoryName(Path.GetFullPath(configPath)); + var configRoot = string.IsNullOrEmpty(configDir) ? Directory.GetCurrentDirectory() : configDir; + var backupRoot = projectRoot is not null && FileSystemPathGuard.IsPathWithinRoot(configRoot, projectRoot) + ? projectRoot + : configRoot; + await FileSystemPathGuard.WriteFileAtomicallyWithinRootAsync( backupPath, backupRoot, diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/SkillVersionStore.cs b/src/Cli/Microsoft.Maui.Cli/Ai/SkillVersionStore.cs index 46ca4e026..2ada90697 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/SkillVersionStore.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/SkillVersionStore.cs @@ -3,6 +3,7 @@ using System.Text.Json; using System.Text.Json.Nodes; +using System.Text; using Microsoft.Maui.Cli.Ai.Models; namespace Microsoft.Maui.Cli.Ai; @@ -59,6 +60,12 @@ public static async Task WriteAsync(string skillDir, InstalledSkillVersion versi var path = Path.Combine(skillDir, VersionFileName); var json = JsonSerializer.Serialize(version, s_indentedJsonContext.InstalledSkillVersion); - await File.WriteAllTextAsync(path, json, ct).ConfigureAwait(false); + var written = await FileSystemPathGuard.WriteFileAtomicallyWithinRootAsync( + path, + skillDir, + Encoding.UTF8.GetBytes(json), + ct).ConfigureAwait(false); + if (!written) + throw new IOException($"Could not write skill version metadata to '{path}'."); } } diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.AssetStatus.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.AssetStatus.cs index c2485fb07..b82f7141a 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.AssetStatus.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.AssetStatus.cs @@ -11,6 +11,8 @@ namespace Microsoft.Maui.Cli.Commands; public static partial class AiCommands { + const long MaxLocalAgentStatusBytes = 1024 * 1024; + internal sealed record AiAssetStatusRow( string Item, string Type, @@ -171,7 +173,13 @@ internal static List GetInstalledAgentStatusRows(string workin break; } - var localBytes = await File.ReadAllBytesAsync(localPath, ct).ConfigureAwait(false); + var localBytes = await TryReadLocalAssetBytesAsync(localPath, ct).ConfigureAwait(false); + if (localBytes is null) + { + status = "Unknown"; + break; + } + if (!remoteBytes.SequenceEqual(localBytes)) { status = "Update available"; @@ -191,6 +199,29 @@ internal static List GetInstalledAgentStatusRows(string workin return rows; } + static async Task TryReadLocalAssetBytesAsync(string localPath, CancellationToken ct) + { + try + { + if (FileSystemPathGuard.IsReparsePoint(localPath)) + return null; + + var info = new FileInfo(localPath); + if (!info.Exists || info.Length > MaxLocalAgentStatusBytes) + return null; + + return await File.ReadAllBytesAsync(localPath, ct).ConfigureAwait(false); + } + catch (IOException) + { + return null; + } + catch (UnauthorizedAccessException) + { + return null; + } + } + internal static bool NeedsUpdate(AiAssetStatusRow row, bool force) => force || row.Status is "Missing" or "missing" or "Update available" or "update-available-from-current-cli" or "installed-from-different-cli-same-version" or "installed-from-newer-cli" or "dirty" or "unknown-or-unmanaged"; diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs index d99809033..c5e9fc4a3 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs @@ -349,7 +349,7 @@ static string ShortCommit(string? commit) OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; internal static bool HasUpdateInstallFailures(IEnumerable skillFileCounts, IEnumerable assetFileCounts) - => skillFileCounts.Any(files => files <= 0) || assetFileCounts.Any(files => files < 0); + => skillFileCounts.Any(files => files < 0) || assetFileCounts.Any(files => files < 0); internal static bool HasUpdateFilterMatches( bool filterSpecified, From 90b194300db01edddb16ab4ded345a7d5d69966b Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Thu, 21 May 2026 23:08:17 +0200 Subject: [PATCH 27/31] Address MAUI AI review hardening Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../McpConfiguratorTests.cs | 8 +- .../SkillInstallerTests.cs | 2 + .../Ai/FileSystemPathGuard.cs | 9 +- .../Microsoft.Maui.Cli/Ai/McpConfigurator.cs | 118 ++++++++++-------- .../Microsoft.Maui.Cli/Ai/SkillInstaller.cs | 3 +- .../Commands/AiCommands.Add.cs | 15 ++- .../Commands/AiCommands.Init.cs | 39 +++++- 7 files changed, 126 insertions(+), 68 deletions(-) diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs index 8e1aaad66..2abfd1148 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/McpConfiguratorTests.cs @@ -137,10 +137,11 @@ await File.WriteAllTextAsync(configPath, """ SkillsDirectory = Path.Combine(_tempDir, ".github", "skills") }; - var result = await McpConfigurator.ConfigureAsync(env); + var result = await McpConfigurator.ConfigureWithResultAsync(env, projectRoot: null); - Assert.True(result); + Assert.True(result.Success); var backupPath = Path.Combine(configDir, "mcp.json.bak"); + Assert.Equal(backupPath, result.BackupPath); Assert.True(File.Exists(backupPath)); var backup = await File.ReadAllTextAsync(backupPath); Assert.Contains("// Existing user MCP server.", backup); @@ -215,8 +216,9 @@ await File.WriteAllTextAsync(configPath, """ var result = await McpConfigurator.ConfigureAsync(env); - Assert.True(result); + Assert.False(result); Assert.Equal("outside content", await File.ReadAllTextAsync(outsideFile)); + Assert.Contains("// Existing user MCP server.", await File.ReadAllTextAsync(configPath)); } [Fact] diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs index efa941283..a29b50389 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs @@ -59,6 +59,8 @@ public async Task InstallSkillAsync_InvalidName_PathSeparator_ReturnsNegativeOne } [Theory] + [InlineData("")] + [InlineData(" ")] [InlineData(".")] [InlineData("bad/name")] [InlineData("bad\\name")] diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/FileSystemPathGuard.cs b/src/Cli/Microsoft.Maui.Cli/Ai/FileSystemPathGuard.cs index d20906a69..3e02285e6 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/FileSystemPathGuard.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/FileSystemPathGuard.cs @@ -73,12 +73,16 @@ internal static async Task WriteFileAtomicallyWithinRootAsync( return false; await File.WriteAllBytesAsync(tempPath, content, ct).ConfigureAwait(false); - if (!IsPathWithinRoot(tempPath, root) || - !IsSafeDestination(fullDestinationPath, destinationDirectory, root)) + if (!IsPathWithinRoot(tempPath, root)) { return false; } + // Re-evaluate the destination immediately before the replace so a symlink + // swap between directory creation and the final move cannot redirect writes. + if (!IsSafeDestination(fullDestinationPath, destinationDirectory, root)) + return false; + File.Move(tempPath, fullDestinationPath, overwrite: true); return true; } @@ -118,5 +122,6 @@ static string ResolveExistingFileSystemEntry(string path) static bool IsSafeDestination(string destinationPath, string destinationDirectory, string root) => IsPathWithinRoot(destinationDirectory, root) && IsPathWithinRoot(destinationPath, root) && + !IsReparsePoint(destinationDirectory) && !IsReparsePoint(destinationPath); } diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs b/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs index 7a6456a88..b2ee255aa 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/McpConfigurator.cs @@ -40,21 +40,31 @@ public static Task ConfigureAsync(DetectedEnvironment env, CancellationTok => ConfigureAsync(env, projectRoot: null, ct); public static async Task ConfigureAsync(DetectedEnvironment env, string? projectRoot, CancellationToken ct = default) + => (await ConfigureWithResultAsync(env, projectRoot, ct).ConfigureAwait(false)).Success; + + public static async Task ConfigureWithResultAsync( + DetectedEnvironment env, + string? projectRoot, + CancellationToken ct = default) { try { var configPath = env.McpConfigPath; var configDir = Path.GetDirectoryName(configPath); + var writeRoot = GetConfigWriteRoot(configPath, projectRoot, env.Kind); + if (writeRoot is null) + return McpConfigurationResult.Failure; + if (!string.IsNullOrEmpty(configDir)) { - if (projectRoot is not null && - env.Kind != AgentEnvironmentKind.CopilotCli && - !FileSystemPathGuard.IsPathWithinRoot(configDir, projectRoot)) + if (!FileSystemPathGuard.IsPathWithinRoot(configDir, writeRoot)) { - return false; + return McpConfigurationResult.Failure; } Directory.CreateDirectory(configDir); + if (!FileSystemPathGuard.IsPathWithinRoot(configDir, writeRoot)) + return McpConfigurationResult.Failure; } JsonObject root; @@ -64,7 +74,7 @@ public static async Task ConfigureAsync(DetectedEnvironment env, string? p { existingJson = await File.ReadAllTextAsync(configPath, ct).ConfigureAwait(false); if (JsonNode.Parse(existingJson, documentOptions: s_jsonDocumentOptions) is not JsonObject existingRoot) - return false; + return McpConfigurationResult.Failure; root = existingRoot; backupExistingConfig = ContainsJsonComments(existingJson); @@ -85,29 +95,36 @@ public static async Task ConfigureAsync(DetectedEnvironment env, string? p : EnsureStandardEntry(root, serverEntry); if (configureResult == ConfigureResult.AlreadyConfigured) - return true; + return McpConfigurationResult.SuccessResult; if (configureResult == ConfigureResult.IncompatibleSchema) - return false; + return McpConfigurationResult.Failure; var options = new JsonSerializerOptions { WriteIndented = true }; + string? backupPath = null; if (backupExistingConfig && existingJson is not null) - await WriteBackupAsync(configPath, existingJson, projectRoot, ct).ConfigureAwait(false); + { + backupPath = await WriteBackupAsync(configPath, existingJson, writeRoot, ct).ConfigureAwait(false); + if (backupPath is null) + return McpConfigurationResult.Failure; + } - await WriteAtomicAsync(configPath, root.ToJsonString(options), ct).ConfigureAwait(false); + var wroteConfig = await WriteAtomicAsync(configPath, root.ToJsonString(options), writeRoot, ct).ConfigureAwait(false); - return true; + return wroteConfig + ? new McpConfigurationResult(true, backupPath) + : McpConfigurationResult.Failure; } catch (IOException) { - return false; + return McpConfigurationResult.Failure; } catch (UnauthorizedAccessException) { - return false; + return McpConfigurationResult.Failure; } catch (JsonException) { - return false; + return McpConfigurationResult.Failure; } } @@ -180,38 +197,44 @@ static bool IsExpectedServerEntry(JsonNode? server) args[1]?.GetValue() == "mcp"; } - static async Task WriteAtomicAsync(string configPath, string contents, CancellationToken ct) + static Task WriteAtomicAsync(string configPath, string contents, string writeRoot, CancellationToken ct) + => FileSystemPathGuard.WriteFileAtomicallyWithinRootAsync( + configPath, + writeRoot, + Encoding.UTF8.GetBytes(contents), + ct); + + static async Task WriteBackupAsync(string configPath, string contents, string writeRoot, CancellationToken ct) { - var configDir = Path.GetDirectoryName(configPath); - var tempDir = string.IsNullOrEmpty(configDir) ? Directory.GetCurrentDirectory() : configDir; - var tempPath = Path.Combine(tempDir, $".{Path.GetFileName(configPath)}.{Guid.NewGuid():N}.tmp"); + var backupPath = GetBackupPath(configPath); + var wroteBackup = await FileSystemPathGuard.WriteFileAtomicallyWithinRootAsync( + backupPath, + writeRoot, + Encoding.UTF8.GetBytes(contents), + ct).ConfigureAwait(false); - try - { - await File.WriteAllTextAsync(tempPath, contents, ct).ConfigureAwait(false); - File.Move(tempPath, configPath, overwrite: true); - } - finally - { - if (File.Exists(tempPath)) - TryDeleteFile(tempPath); - } + return wroteBackup ? backupPath : null; } - static async Task WriteBackupAsync(string configPath, string contents, string? projectRoot, CancellationToken ct) + static string? GetConfigWriteRoot(string configPath, string? projectRoot, AgentEnvironmentKind kind) { - var backupPath = GetBackupPath(configPath); var configDir = Path.GetDirectoryName(Path.GetFullPath(configPath)); var configRoot = string.IsNullOrEmpty(configDir) ? Directory.GetCurrentDirectory() : configDir; - var backupRoot = projectRoot is not null && FileSystemPathGuard.IsPathWithinRoot(configRoot, projectRoot) - ? projectRoot - : configRoot; - await FileSystemPathGuard.WriteFileAtomicallyWithinRootAsync( - backupPath, - backupRoot, - Encoding.UTF8.GetBytes(contents), - ct).ConfigureAwait(false); + if (projectRoot is not null && kind != AgentEnvironmentKind.CopilotCli) + return projectRoot; + + if (projectRoot is not null && FileSystemPathGuard.IsPathWithinRoot(configRoot, projectRoot)) + return projectRoot; + + var userHome = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrWhiteSpace(userHome) && + FileSystemPathGuard.IsPathWithinRoot(configRoot, userHome)) + { + return userHome; + } + + return projectRoot is null ? configRoot : null; } static string GetBackupPath(string configPath) @@ -236,19 +259,10 @@ static bool ContainsJsonComments(string contents) return false; } - static void TryDeleteFile(string path) - { - try - { - File.Delete(path); - } - catch (IOException) - { - // Best-effort temp cleanup should not hide the config write result. - } - catch (UnauthorizedAccessException) - { - // Best-effort temp cleanup should not hide the config write result. - } - } +} + +internal sealed record McpConfigurationResult(bool Success, string? BackupPath) +{ + public static McpConfigurationResult SuccessResult { get; } = new(true, null); + public static McpConfigurationResult Failure { get; } = new(false, null); } diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs b/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs index 3ba4731e7..62afc6a3d 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs @@ -40,7 +40,8 @@ internal static class SkillInstaller bool force, CancellationToken ct = default) { - if (skill.Name is "." or ".." || + if (string.IsNullOrWhiteSpace(skill.Name) || + skill.Name is "." or ".." || skill.Name.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || skill.Name.Contains("..") || skill.Name.Contains('/') || diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Add.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Add.cs index f78bb2614..6b3ad0aec 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Add.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Add.cs @@ -140,15 +140,14 @@ static Command CreateAddCommand() } // Configure MCP + var mcpResults = new List<(string Environment, bool Configured, string? BackupPath)>(); if (!noMcp) { foreach (var env in environments) { - var ok = await McpConfigurator.ConfigureAsync(env, workingDir, ct); - if (ok) - formatter.WriteSuccess($"MCP configured for {env.Kind}"); - else - formatter.WriteWarning($"Could not configure MCP for {env.Kind}"); + var result = await McpConfigurator.ConfigureWithResultAsync(env, workingDir, ct); + mcpResults.Add((env.Kind.ToString(), result.Success, result.BackupPath)); + WriteMcpConfigurationMessage(formatter, env.Kind, result, useJson); } } @@ -164,6 +163,12 @@ static Command CreateAddCommand() ["environment"] = r.Env, ["files"] = r.Files, ["path"] = r.Path + }).ToArray()), + ["mcp"] = new JsonArray(mcpResults.Select(r => (JsonNode)new JsonObject + { + ["environment"] = r.Environment, + ["configured"] = r.Configured, + ["backupPath"] = r.BackupPath }).ToArray()) }; formatter.Write(jsonResult); diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs index 327effa38..21631c840 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs @@ -269,15 +269,14 @@ static Command CreateInitCommand() } // Step 9: Configure MCP + var mcpResults = new List<(string Environment, bool Configured, string? BackupPath)>(); if (!noMcp) { foreach (var env in environments) { - var ok = await McpConfigurator.ConfigureAsync(env, workingDir, ct); - if (ok) - formatter.WriteSuccess($"MCP configured for {env.Kind}"); - else - formatter.WriteWarning($"Could not configure MCP for {env.Kind}"); + var result = await McpConfigurator.ConfigureWithResultAsync(env, workingDir, ct); + mcpResults.Add((env.Kind.ToString(), result.Success, result.BackupPath)); + WriteMcpConfigurationMessage(formatter, env.Kind, result, useJson); } if (!useJson) @@ -316,6 +315,12 @@ static Command CreateInitCommand() ["type"] = r.Type, ["files"] = r.Files, ["path"] = r.Path + }).ToArray()), + ["mcp"] = new JsonArray(mcpResults.Select(r => (JsonNode)new JsonObject + { + ["environment"] = r.Environment, + ["configured"] = r.Configured, + ["backupPath"] = r.BackupPath }).ToArray()) }; formatter.Write(jsonResult); @@ -363,6 +368,30 @@ static Command CreateInitCommand() return (skills, agentAssets); } + static void WriteMcpConfigurationMessage( + IOutputFormatter formatter, + AgentEnvironmentKind environment, + McpConfigurationResult result, + bool useJson) + { + if (useJson) + return; + + if (result.Success) + { + formatter.WriteSuccess($"MCP configured for {environment}"); + if (!string.IsNullOrEmpty(result.BackupPath)) + { + formatter.WriteWarning( + $"Existing JSONC comments in the MCP config for {environment} were removed; original saved to {result.BackupPath}"); + } + } + else + { + formatter.WriteWarning($"Could not configure MCP for {environment}"); + } + } + static List SelectSkills( List allSkills, string[]? skillFilter, From bcaaed30e8683570fc43b07e1dfc5c036a05a14a Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Thu, 21 May 2026 23:32:44 +0200 Subject: [PATCH 28/31] Address MAUI AI status review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AiCommandsTests.cs | 102 ++++++++++++++++++ .../MarketplaceClientTests.cs | 3 +- .../Ai/MarketplaceClient.cs | 8 +- .../Commands/AiCommands.Add.cs | 4 + .../Commands/AiCommands.AssetStatus.cs | 41 ++++++- .../Commands/AiCommands.Init.cs | 16 +++ .../Commands/AiCommands.List.cs | 4 + .../Commands/AiCommands.Status.cs | 5 + .../Commands/AiCommands.Update.cs | 4 + 9 files changed, 183 insertions(+), 4 deletions(-) diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs index cef823d05..4ab1a8187 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs @@ -406,6 +406,95 @@ public async Task GetRemoteAgentStatusRowsAsync_OversizedLocalAgent_ReturnsUnkno } } + [Fact] + public async Task GetRemoteAgentStatusRowsAsync_UnsafeLocalPath_ReturnsUnknown() + { + using var http = new HttpClient(new StaticContentHandler("remote content")); + var asset = new RepositoryAssetInfo + { + Name = "expert-reviewer", + Category = "agent", + DestinationRoot = ".github/agents", + Files = ["../outside.agent.md"] + }; + + var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + try + { + Directory.CreateDirectory(tempDir); + var rows = await AiCommands.GetRemoteAgentStatusRowsAsync( + http, + [asset], + tempDir, + "owner/repo", + "main", + CancellationToken.None); + + var row = Assert.Single(rows); + Assert.Equal("Unknown", row.Row.Status); + } + finally + { + if (Directory.Exists(tempDir)) + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public async Task GetMarketplaceSkillStatusRowsAsync_SymlinkedSkillDirectory_Skips() + { + var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + try + { + var skillsDir = Path.Combine(tempDir, "skills"); + var outsideDir = Path.Combine(tempDir, "outside"); + Directory.CreateDirectory(skillsDir); + Directory.CreateDirectory(outsideDir); + + if (!TryCreateDirectorySymlink(Path.Combine(skillsDir, "evil-skill"), outsideDir)) + return; + + var env = new DetectedEnvironment + { + Kind = AgentEnvironmentKind.Claude, + SkillsDirectory = skillsDir + }; + + var rows = await AiCommands.GetMarketplaceSkillStatusRowsAsync( + [env], + checkUpdates: false, + http: null, + "owner/repo", + "main", + CancellationToken.None); + + Assert.Empty(rows); + } + finally + { + if (Directory.Exists(tempDir)) + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public void GetLocalOnlyAgentStatusRows_ExcludesRemoteAgents() + { + var remoteAssets = new[] + { + new RepositoryAssetInfo { Name = "current-agent", Category = "agent" } + }; + var installedRows = new[] + { + new AiCommands.AiAssetStatusRow("current-agent", "agent", "GitHub Copilot", "Yes", "Installed", ".github/agents/current-agent.agent.md"), + new AiCommands.AiAssetStatusRow("legacy-agent", "agent", "GitHub Copilot", "Yes", "Installed", ".github/agents/legacy-agent.agent.md") + }; + + var row = Assert.Single(AiCommands.GetLocalOnlyAgentStatusRows(remoteAssets, installedRows)); + + Assert.Equal("legacy-agent", row.Item); + } + [Theory] [InlineData(false, 0, 0, 0, true)] [InlineData(true, 1, 0, 0, true)] @@ -474,4 +563,17 @@ protected override Task SendAsync(HttpRequestMessage reques Content = new ByteArrayContent(System.Text.Encoding.UTF8.GetBytes(content)) }); } + + static bool TryCreateDirectorySymlink(string linkPath, string targetPath) + { + try + { + Directory.CreateSymbolicLink(linkPath, targetPath); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return false; + } + } } diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs index a5f36ef41..2af7a8bdf 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs @@ -414,10 +414,11 @@ public async Task FetchTreeEntriesAsync_TruncatedTree_Throws() }); using var http = new HttpClient(handler); - var exception = await Assert.ThrowsAsync( + var exception = await Assert.ThrowsAsync( () => MarketplaceClient.FetchTreeEntriesAsync(http, "owner/repo", "main")); Assert.Contains("truncated", exception.Message); + Assert.Contains("Use a smaller --repo/--branch", exception.Message); } [Fact] diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs b/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs index e1da4a1f9..525e68045 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs @@ -79,7 +79,7 @@ internal static class MarketplaceClient var treeNode = JsonNode.Parse(treeJson); if (treeNode?["truncated"]?.GetValue() == true) - throw new InvalidOperationException($"GitHub tree for '{repo}@{branch}' is truncated; cannot safely discover all MAUI AI assets."); + throw new GitHubTreeTruncatedException(repo, branch); var treeArray = treeNode?["tree"]?.AsArray(); if (treeArray is null) @@ -534,3 +534,9 @@ static bool IsValidRepoSegment(string segment) return buffer.ToArray(); } } + +internal sealed class GitHubTreeTruncatedException(string repo, string branch) : InvalidOperationException( + $"GitHub tree for '{repo}@{branch}' is truncated; cannot safely discover all MAUI AI assets. " + + "Use a smaller --repo/--branch asset source or split the AI assets into a smaller repository, because installing from a truncated tree could miss files.") +{ +} diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Add.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Add.cs index 6b3ad0aec..8bde379cd 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Add.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Add.cs @@ -181,6 +181,10 @@ static Command CreateAddCommand() formatter.WriteError(new Exception($"Network error: {ex.Message}. Check your connection or set GITHUB_TOKEN for higher rate limits.")); return 1; } + catch (GitHubTreeTruncatedException ex) + { + return HandleGitHubTreeTruncatedException(formatter, ex); + } catch (Exception ex) { return Program.HandleCommandException(formatter, ex); diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.AssetStatus.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.AssetStatus.cs index b82f7141a..d71211c72 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.AssetStatus.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.AssetStatus.cs @@ -59,7 +59,7 @@ internal static IEnumerable GetDevFlowStatusRows(JsonObject re } } - static async Task> GetMarketplaceSkillStatusRowsAsync( + internal static async Task> GetMarketplaceSkillStatusRowsAsync( IEnumerable environments, bool checkUpdates, HttpClient? http, @@ -75,6 +75,9 @@ static async Task> GetMarketplaceSkillStatusRowsAsync( foreach (var skillDir in Directory.GetDirectories(env.SkillsDirectory).OrderBy(path => path, StringComparer.Ordinal)) { + if (FileSystemPathGuard.IsReparsePoint(skillDir)) + continue; + var skillName = Path.GetFileName(skillDir); if (IsDevFlowManagedSkillName(skillName)) continue; @@ -145,6 +148,17 @@ internal static List GetInstalledAgentStatusRows(string workin Path.Combine(workingDir, asset.DestinationRoot))) .ToList(); + internal static IEnumerable GetLocalOnlyAgentStatusRows( + IEnumerable remoteAssets, + IEnumerable installedRows) + { + var remoteNames = new HashSet( + remoteAssets.Select(asset => asset.Name), + StringComparer.OrdinalIgnoreCase); + + return installedRows.Where(row => !remoteNames.Contains(row.Item)); + } + internal static async Task> GetRemoteAgentStatusRowsAsync( HttpClient http, IEnumerable assets, @@ -159,7 +173,12 @@ internal static List GetInstalledAgentStatusRows(string workin var status = "Up to date"; foreach (var filePath in asset.Files) { - var localPath = RepositoryAssetInstaller.GetAssetFilePath(workingDir, asset, filePath); + if (!TryGetContainedAssetFilePath(workingDir, asset, filePath, out var localPath)) + { + status = "Unknown"; + break; + } + if (!File.Exists(localPath)) { status = "Missing"; @@ -199,6 +218,24 @@ internal static List GetInstalledAgentStatusRows(string workin return rows; } + static bool TryGetContainedAssetFilePath( + string workingDir, + RepositoryAssetInfo asset, + string filePath, + out string localPath) + { + localPath = string.Empty; + try + { + localPath = RepositoryAssetInstaller.GetAssetFilePath(workingDir, asset, filePath); + return FileSystemPathGuard.IsPathWithinRoot(localPath, workingDir); + } + catch (InvalidOperationException) + { + return false; + } + } + static async Task TryReadLocalAssetBytesAsync(string localPath, CancellationToken ct) { try diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs index 21631c840..4cc7d0025 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs @@ -111,6 +111,12 @@ static Command CreateInitCommand() } } + if (environments.Count == 0) + { + formatter.WriteWarning("Failed to detect an agent environment after creating .claude/."); + return 1; + } + var envWord = environments.Count == 1 ? "environment" : "environments"; formatter.WriteInfo($"Detected {environments.Count} {envWord}: {string.Join(", ", environments.Select(e => e.Kind))}"); @@ -350,6 +356,10 @@ static Command CreateInitCommand() formatter.WriteError(new Exception($"Network error: {ex.Message}. Check your connection or set GITHUB_TOKEN for higher rate limits.")); return 1; } + catch (GitHubTreeTruncatedException ex) + { + return HandleGitHubTreeTruncatedException(formatter, ex); + } catch (Exception ex) { return Program.HandleCommandException(formatter, ex); @@ -392,6 +402,12 @@ static void WriteMcpConfigurationMessage( } } + static int HandleGitHubTreeTruncatedException(IOutputFormatter formatter, GitHubTreeTruncatedException exception) + { + formatter.WriteError(new Exception(exception.Message)); + return 1; + } + static List SelectSkills( List allSkills, string[]? skillFilter, diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.List.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.List.cs index 0c159c2a7..f1dc745f2 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.List.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.List.cs @@ -97,6 +97,10 @@ static Command CreateListCommand() formatter.WriteError(new Exception($"Network error: {ex.Message}. Check your connection or set GITHUB_TOKEN for higher rate limits.")); return 1; } + catch (GitHubTreeTruncatedException ex) + { + return HandleGitHubTreeTruncatedException(formatter, ex); + } catch (Exception ex) { return Program.HandleCommandException(formatter, ex); diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Status.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Status.cs index c492d1377..d07efc0e0 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Status.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Status.cs @@ -54,6 +54,7 @@ static Command CreateStatusCommand() var agentAssets = await RepositoryAssetInstaller.GetCopilotAgentsAsync(http, repo, branch, treeEntries, ct); var agentRows = await GetRemoteAgentStatusRowsAsync(http, agentAssets, workingDir, repo, branch, ct); rows.AddRange(agentRows.Select(row => row.Row)); + rows.AddRange(GetLocalOnlyAgentStatusRows(agentAssets, GetInstalledAgentStatusRows(workingDir))); } else { @@ -98,6 +99,10 @@ static Command CreateStatusCommand() formatter.WriteError(new Exception($"Network error: {ex.Message}. Check your connection or set GITHUB_TOKEN for higher rate limits.")); return 1; } + catch (GitHubTreeTruncatedException ex) + { + return HandleGitHubTreeTruncatedException(formatter, ex); + } catch (Exception ex) { return Program.HandleCommandException(formatter, ex); diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs index c5e9fc4a3..e27afadf4 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs @@ -331,6 +331,10 @@ version.Commit is null || formatter.WriteError(new Exception($"Network error: {ex.Message}. Check your connection or set GITHUB_TOKEN for higher rate limits.")); return 1; } + catch (GitHubTreeTruncatedException ex) + { + return HandleGitHubTreeTruncatedException(formatter, ex); + } catch (Exception ex) { return Program.HandleCommandException(formatter, ex); From 5e6ffed98823a92b9c50164b83047deb36cbacbf Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Thu, 21 May 2026 23:57:17 +0200 Subject: [PATCH 29/31] Tighten MAUI AI skill path scanning Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AiCommandsTests.cs | 28 ++++++++++++++++ .../SkillInstallerTests.cs | 32 +++++++++++++++++++ .../Microsoft.Maui.Cli/Ai/SkillInstaller.cs | 24 +++++++++----- .../Commands/AiCommands.AssetStatus.cs | 24 ++++++++++---- .../Commands/AiCommands.Update.cs | 5 +-- 5 files changed, 94 insertions(+), 19 deletions(-) diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs index 4ab1a8187..67f420a6f 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs @@ -477,6 +477,34 @@ public async Task GetMarketplaceSkillStatusRowsAsync_SymlinkedSkillDirectory_Ski } } + [Fact] + public void EnumerateSkillDirectories_SymlinkedSkillsDirectory_Skips() + { + var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + try + { + var outsideDir = Path.Combine(tempDir, "outside"); + var skillsDir = Path.Combine(tempDir, "skills"); + Directory.CreateDirectory(outsideDir); + + if (!TryCreateDirectorySymlink(skillsDir, outsideDir)) + return; + + var env = new DetectedEnvironment + { + Kind = AgentEnvironmentKind.Claude, + SkillsDirectory = skillsDir + }; + + Assert.Empty(AiCommands.EnumerateSkillDirectories(env)); + } + finally + { + if (Directory.Exists(tempDir)) + Directory.Delete(tempDir, recursive: true); + } + } + [Fact] public void GetLocalOnlyAgentStatusRows_ExcludesRemoteAgents() { diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs index a29b50389..910066078 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/SkillInstallerTests.cs @@ -112,6 +112,38 @@ public async Task InstallSkillAsync_SymlinkedSkillsDirectoryOutsideProject_Retur Assert.False(Directory.Exists(Path.Combine(outsideRoot, "skills"))); } + [Fact] + public async Task InstallSkillAsync_SymlinkedSkillsDirectory_ReturnsNegativeOne() + { + var projectRoot = Path.Combine(_tempDir, "project"); + var outsideRoot = Path.Combine(_tempDir, "outside"); + Directory.CreateDirectory(projectRoot); + Directory.CreateDirectory(outsideRoot); + + if (!TryCreateDirectorySymlink(Path.Combine(projectRoot, "skills"), outsideRoot)) + return; + + var skill = new SkillInfo + { + Name = "safe-name", + RemotePath = ".github/skills/safe-name", + Files = [".github/skills/safe-name/SKILL.md"] + }; + var env = new DetectedEnvironment + { + Kind = AgentEnvironmentKind.Claude, + SkillsDirectory = Path.Combine(projectRoot, "skills") + }; + using var http = new HttpClient(new SuccessfulInstallHandler()); + + var (filesInstalled, installPath) = await SkillInstaller.InstallSkillAsync( + http, skill, env, projectRoot, "owner/repo", "main", force: true); + + Assert.Equal(-1, filesInstalled); + Assert.Equal(string.Empty, installPath); + Assert.False(File.Exists(Path.Combine(outsideRoot, "SKILL.md"))); + } + [Fact] public async Task InstallSkillAsync_ValidName_DoesNotReturnNegativeOne() { diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs b/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs index 62afc6a3d..853c2fe79 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs @@ -56,21 +56,29 @@ skill.Name is "." or ".." || if (!FileSystemPathGuard.IsPathWithinRoot(skillsDir, projectRoot)) return (-1, string.Empty); - var installPath = Path.Combine(skillsDir, skill.Name); + Directory.CreateDirectory(skillsDir); + if (FileSystemPathGuard.IsReparsePoint(skillsDir) || + !FileSystemPathGuard.IsPathWithinRoot(skillsDir, projectRoot)) + { + return (-1, string.Empty); + } + + var canonicalSkillsDir = FileSystemPathGuard.ResolveCanonicalPath(skillsDir); + if (!FileSystemPathGuard.IsPathWithinRoot(canonicalSkillsDir, projectRoot)) + return (-1, string.Empty); + + var installPath = Path.Combine(canonicalSkillsDir, skill.Name); + var displayInstallPath = Path.Combine(skillsDir, skill.Name); // Skip if already installed and not forcing. if (!force) { var existing = await SkillVersionStore.ReadAsync(installPath, ct).ConfigureAwait(false); if (existing is not null) - return (0, installPath); + return (0, displayInstallPath); } - Directory.CreateDirectory(skillsDir); - if (!FileSystemPathGuard.IsPathWithinRoot(skillsDir, projectRoot)) - return (-1, string.Empty); - - var tempInstallPath = Path.Combine(skillsDir, $".{skill.Name}.{Guid.NewGuid():N}.tmp"); + var tempInstallPath = Path.Combine(canonicalSkillsDir, $".{skill.Name}.{Guid.NewGuid():N}.tmp"); Directory.CreateDirectory(tempInstallPath); try @@ -100,7 +108,7 @@ skill.Name is "." or ".." || ReplaceDirectory(tempInstallPath, installPath); - return (filesInstalled, installPath); + return (filesInstalled, displayInstallPath); } finally { diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.AssetStatus.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.AssetStatus.cs index d71211c72..065e59dc2 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.AssetStatus.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.AssetStatus.cs @@ -70,14 +70,8 @@ internal static async Task> GetMarketplaceSkillStatusRows var rows = new List(); foreach (var env in GetUniqueSkillInstallEnvironments(environments)) { - if (!Directory.Exists(env.SkillsDirectory)) - continue; - - foreach (var skillDir in Directory.GetDirectories(env.SkillsDirectory).OrderBy(path => path, StringComparer.Ordinal)) + foreach (var skillDir in EnumerateSkillDirectories(env)) { - if (FileSystemPathGuard.IsReparsePoint(skillDir)) - continue; - var skillName = Path.GetFileName(skillDir); if (IsDevFlowManagedSkillName(skillName)) continue; @@ -118,6 +112,22 @@ internal static async Task> GetMarketplaceSkillStatusRows return rows; } + internal static IEnumerable EnumerateSkillDirectories(DetectedEnvironment env) + { + if (FileSystemPathGuard.IsReparsePoint(env.SkillsDirectory) || + !Directory.Exists(env.SkillsDirectory) || + FileSystemPathGuard.IsReparsePoint(env.SkillsDirectory)) + { + yield break; + } + + foreach (var skillDir in Directory.GetDirectories(env.SkillsDirectory).OrderBy(path => path, StringComparer.Ordinal)) + { + if (!FileSystemPathGuard.IsReparsePoint(skillDir)) + yield return skillDir; + } + } + internal static async Task<(bool IsCheckable, string? RemoteSha)> TryGetRemoteCommitShaAsync( HttpClient http, string repo, diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs index e27afadf4..82a3366ca 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Update.cs @@ -103,10 +103,7 @@ static Command CreateUpdateCommand() foreach (var env in environments) { - if (!Directory.Exists(env.SkillsDirectory)) - continue; - - foreach (var skillDir in Directory.GetDirectories(env.SkillsDirectory)) + foreach (var skillDir in EnumerateSkillDirectories(env)) { var resolvedPath = Path.GetFullPath(skillDir); if (!processedPaths.Add(resolvedPath)) From 8dfdbc7f6264d10433efacd68e741ceb31bbdd08 Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Fri, 22 May 2026 00:20:43 +0200 Subject: [PATCH 30/31] Harden MAUI AI parsing and temp writes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MarketplaceClientTests.cs | 18 ++++++++++++++++ .../Ai/FileSystemPathGuard.cs | 15 ++++++++++++- .../Ai/MarketplaceClient.cs | 21 ++++++++++++++++++- .../Commands/AiCommands.Init.cs | 4 ++-- .../Commands/AiCommands.List.cs | 5 +---- 5 files changed, 55 insertions(+), 8 deletions(-) diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs index 2af7a8bdf..1004856ce 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/MarketplaceClientTests.cs @@ -171,6 +171,24 @@ public void ParseFrontmatter_CaseInsensitiveKeys() Assert.Equal("Case insensitive keys", description); } + [Fact] + public void ParseFrontmatter_EmbeddedDelimiterText_DoesNotEndFrontmatter() + { + var content = """ + --- + name: delimiter-skill + description: >- + Uses --- inside the description text. + --- + # Skill + """; + + var (name, description) = MarketplaceClient.ParseFrontmatter(content); + + Assert.Equal("delimiter-skill", name); + Assert.Equal("Uses --- inside the description text.", description); + } + [Theory] [InlineData("---\nname: a\ndescription: b\n---", "a", "b")] [InlineData("---\nname: spaced \ndescription: also spaced \n---", "spaced", "also spaced")] diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/FileSystemPathGuard.cs b/src/Cli/Microsoft.Maui.Cli/Ai/FileSystemPathGuard.cs index 3e02285e6..183d184d4 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/FileSystemPathGuard.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/FileSystemPathGuard.cs @@ -68,7 +68,7 @@ internal static async Task WriteFileAtomicallyWithinRootAsync( try { - Directory.CreateDirectory(tempDirectory); + CreatePrivateDirectory(tempDirectory); if (IsReparsePoint(tempDirectory) || !IsPathWithinRoot(tempDirectory, root)) return false; @@ -124,4 +124,17 @@ static bool IsSafeDestination(string destinationPath, string destinationDirector IsPathWithinRoot(destinationPath, root) && !IsReparsePoint(destinationDirectory) && !IsReparsePoint(destinationPath); + + static void CreatePrivateDirectory(string path) + { + if (OperatingSystem.IsWindows()) + { + Directory.CreateDirectory(path); + return; + } + + Directory.CreateDirectory( + path, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } } diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs b/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs index 525e68045..3bf9f4d33 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/MarketplaceClient.cs @@ -349,7 +349,7 @@ internal static (string? Name, string? Description) ParseFrontmatter(string cont if (!trimmed.StartsWith("---", StringComparison.Ordinal)) return (name, description); - var endIndex = trimmed.IndexOf("---", 3, StringComparison.Ordinal); + var endIndex = FindFrontmatterEnd(trimmed); if (endIndex < 0) return (name, description); @@ -394,6 +394,25 @@ internal static (string? Name, string? Description) ParseFrontmatter(string cont return (name, description); } + static int FindFrontmatterEnd(string content) + { + var searchIndex = 3; + while (true) + { + var newlineIndex = content.IndexOf('\n', searchIndex); + if (newlineIndex < 0) + return -1; + + var lineStart = newlineIndex + 1; + var nextNewlineIndex = content.IndexOf('\n', lineStart); + var lineEnd = nextNewlineIndex < 0 ? content.Length : nextNewlineIndex; + if (content[lineStart..lineEnd].Trim() == "---") + return lineStart; + + searchIndex = lineEnd; + } + } + /// /// Strips surrounding whitespace and optional quotes from a YAML value. /// diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs index 4cc7d0025..9cd8138bb 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs @@ -86,7 +86,7 @@ static Command CreateInitCommand() // In CI or force mode, create .claude/ by default var claudeDir = Path.Combine(workingDir, ".claude"); Directory.CreateDirectory(claudeDir); - environments = FilterEnvironments(AgentEnvironmentDetector.Detect(currentDir), envFilter); + environments = FilterEnvironments(AgentEnvironmentDetector.Detect(workingDir), envFilter); } else if (!canCreateDefaultClaudeEnvironment || useJson || isCi || force) { @@ -107,7 +107,7 @@ static Command CreateInitCommand() var claudeDir = Path.Combine(workingDir, ".claude"); Directory.CreateDirectory(claudeDir); - environments = FilterEnvironments(AgentEnvironmentDetector.Detect(currentDir), envFilter); + environments = FilterEnvironments(AgentEnvironmentDetector.Detect(workingDir), envFilter); } } diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.List.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.List.cs index f1dc745f2..b05928744 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.List.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.List.cs @@ -57,10 +57,7 @@ static Command CreateListCommand() var installedSkills = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var env in environments) { - if (!Directory.Exists(env.SkillsDirectory)) - continue; - - foreach (var skillDir in Directory.GetDirectories(env.SkillsDirectory)) + foreach (var skillDir in EnumerateSkillDirectories(env)) { var version = await SkillVersionStore.ReadAsync(skillDir, ct); if (version is not null) From 9ef22dee42736129030e6f9b6de33deac4b32c4b Mon Sep 17 00:00:00 2001 From: Gerald Versluis Date: Fri, 22 May 2026 00:44:35 +0200 Subject: [PATCH 31/31] Track MAUI AI bootstrap failures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AiCommandsTests.cs | 6 +++++ .../Microsoft.Maui.Cli/Ai/SkillInstaller.cs | 18 +++++++++------ .../Commands/AiCommands.Init.cs | 22 +++++++++++++++---- 3 files changed, 35 insertions(+), 11 deletions(-) diff --git a/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs b/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs index 67f420a6f..32bf81812 100644 --- a/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs +++ b/src/Cli/Microsoft.Maui.Cli.UnitTests/AiCommandsTests.cs @@ -343,6 +343,12 @@ public void HasInitInstallFailures_DetectsNegativeFileCounts(int[] skillFileCoun Assert.Equal(expected, AiCommands.HasInitInstallFailures(skillFileCounts, assetFileCounts)); } + [Fact] + public void HasInitInstallFailures_DevFlowFailure_ReturnsTrue() + { + Assert.True(AiCommands.HasInitInstallFailures([], [], devFlowInstallFailed: true)); + } + [Theory] [InlineData(new[] { 1, 2 }, new[] { 1 }, false)] [InlineData(new[] { -2, 1 }, new[] { 1 }, true)] diff --git a/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs b/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs index 853c2fe79..c15219ff7 100644 --- a/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs +++ b/src/Cli/Microsoft.Maui.Cli/Ai/SkillInstaller.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Runtime.ExceptionServices; using Microsoft.Maui.Cli.Ai.Models; namespace Microsoft.Maui.Cli.Ai; @@ -150,23 +149,28 @@ static void ReplaceDirectory(string sourceDirectory, string destinationDirectory var backupDirectory = $"{destinationDirectory}.{Guid.NewGuid():N}.bak"; TryDeleteDirectoryIfExists(backupDirectory); + var movedExistingDirectory = false; + var succeeded = false; if (Directory.Exists(destinationDirectory)) + { Directory.Move(destinationDirectory, backupDirectory); + movedExistingDirectory = true; + } try { Directory.Move(sourceDirectory, destinationDirectory); + succeeded = true; TryDeleteDirectoryIfExists(backupDirectory); } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + finally { - RestoreBackupDirectory(backupDirectory, destinationDirectory, ex); - ExceptionDispatchInfo.Capture(ex).Throw(); - throw; + if (!succeeded && movedExistingDirectory) + RestoreBackupDirectory(backupDirectory, destinationDirectory); } } - static void RestoreBackupDirectory(string backupDirectory, string destinationDirectory, Exception originalException) + static void RestoreBackupDirectory(string backupDirectory, string destinationDirectory) { if (!Directory.Exists(backupDirectory) || Directory.Exists(destinationDirectory)) return; @@ -179,7 +183,7 @@ static void RestoreBackupDirectory(string backupDirectory, string destinationDir { throw new InvalidOperationException( $"Could not replace skill directory '{destinationDirectory}' and could not restore the previous installation.", - new AggregateException(originalException, restoreException)); + restoreException); } } diff --git a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs index 9cd8138bb..fd9d4392d 100644 --- a/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs +++ b/src/Cli/Microsoft.Maui.Cli/Commands/AiCommands.Init.cs @@ -212,6 +212,7 @@ static Command CreateInitCommand() // Step 6: Install DevFlow skills through the DevFlow-owned bundled installer. var devFlowResults = new List<(string Skill, string Target, string Action, string Path)>(); + var devFlowInstallFailed = false; foreach (var target in devFlowTargets) { var result = await DevFlowSkillManager.InstallRecommendedAsync( @@ -223,7 +224,14 @@ static Command CreateInitCommand() confirm: null, ct); - foreach (var row in GetDevFlowResultRows(result, target)) + var targetRows = GetDevFlowResultRows(result, target).ToList(); + if (targetRows.Count == 0) + { + devFlowInstallFailed = true; + formatter.WriteWarning($"Could not install DevFlow skills for {target.DisplayName}"); + } + + foreach (var row in targetRows) { devFlowResults.Add(row); formatter.WriteSuccess($"DevFlow {row.Action} {row.Skill} → {row.Target}"); @@ -294,7 +302,10 @@ static Command CreateInitCommand() summaryRows.AddRange(devFlowResults.Select(r => (r.Skill, "DevFlow", r.Target, r.Action, r.Path))); summaryRows.AddRange(skillResults.Select(r => (r.Skill, "Skill", r.Env, FormatFileResult(r.Files), r.Path))); summaryRows.AddRange(assetResults.Select(r => (r.Asset, r.Type, "GitHub Copilot", FormatFileResult(r.Files), r.Path))); - var hasInstallFailures = HasInitInstallFailures(skillResults.Select(r => r.Files), assetResults.Select(r => r.Files)); + var hasInstallFailures = HasInitInstallFailures( + skillResults.Select(r => r.Files), + assetResults.Select(r => r.Files), + devFlowInstallFailed); if (useJson) { @@ -536,8 +547,11 @@ static string FormatFileResult(int files) => _ => files.ToString() }; - internal static bool HasInitInstallFailures(IEnumerable skillFileCounts, IEnumerable assetFileCounts) - => HasSkillInstallFailures(skillFileCounts) || assetFileCounts.Any(files => files < 0); + internal static bool HasInitInstallFailures( + IEnumerable skillFileCounts, + IEnumerable assetFileCounts, + bool devFlowInstallFailed = false) + => devFlowInstallFailed || HasSkillInstallFailures(skillFileCounts) || assetFileCounts.Any(files => files < 0); internal static string GetInitStatus(bool hasInstallFailures) => hasInstallFailures ? "partial" : "success";