// 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;
using Aspire.Cli.Agents;
using Aspire.Cli.Agents.AspireSkills;
using Aspire.Cli.Agents.OpenCode;
using Aspire.Cli.Commands;
using Aspire.Cli.Interaction;
using Aspire.Cli.Npm;
using Aspire.Cli.Resources;
using Aspire.Cli.Tests.Agents;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Semver;
namespace Aspire.Cli.Tests.Commands;
public class AgentInitClientSelectionTests(ITestOutputHelper outputHelper)
{
[Fact]
public async Task AgentInit_CopilotAppCliAndEditor_AreOneDetectedSelection()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.AgentEnvironmentDetectorFactory = _ => new TestAgentEnvironmentDetector
{
DetectedEnvironments = AgentEnvironment.CopilotApp | AgentEnvironment.CopilotCli | AgentEnvironment.VsCode
};
});
using var provider = services.BuildServiceProvider();
var result = provider.GetRequiredService<RootCommand>().Parse("agent init --skills aspire --mcp=false");
Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
var copilot = provider.GetServices<IAgentClient>().Single(client => string.Equals(client.Id, "copilot", StringComparisons.AgentClientId));
Assert.True(copilot.HasIntegrationRegistration(
workspace.WorkspaceRoot, AgentConfigurationScope.Project, [SkillSource.BuiltIn]));
Assert.False(provider.GetServices<IAgentClient>()
.Single(client => string.Equals(client.Id, "claude", StringComparisons.AgentClientId))
.HasIntegrationRegistration(workspace.WorkspaceRoot, AgentConfigurationScope.Project, [SkillSource.BuiltIn]));
Assert.False(Directory.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills")));
Assert.Equal(AgentEnvironment.CopilotApp | AgentEnvironment.CopilotCli | AgentEnvironment.VsCode, copilot.Environments);
}
[Theory]
[InlineData("project", true, false, null, "copilot")]
[InlineData("project", false, true, null, "copilot")]
[InlineData("project", true, true, null, "copilot")]
[InlineData("project", false, false, null, "copilot")]
[InlineData("global", true, false, null, "copilot")]
[InlineData("global", false, true, null, "copilot")]
[InlineData("global", true, true, null, "copilot")]
[InlineData("global", false, false, null, "copilot")]
[InlineData("project", true, false, nameof(AgentEnvironment.ClaudeCode), "claude")]
[InlineData("project", false, true, nameof(AgentEnvironment.ClaudeCode), "claude")]
[InlineData("project", true, true, nameof(AgentEnvironment.ClaudeCode), "claude")]
[InlineData("project", false, false, nameof(AgentEnvironment.ClaudeCode), "claude")]
[InlineData("global", true, false, nameof(AgentEnvironment.ClaudeCode), "claude")]
[InlineData("global", false, true, nameof(AgentEnvironment.ClaudeCode), "claude")]
[InlineData("global", true, true, nameof(AgentEnvironment.ClaudeCode), "claude")]
[InlineData("global", false, false, nameof(AgentEnvironment.ClaudeCode), "claude")]
[InlineData("project", false, true, nameof(AgentEnvironment.OpenCode), "opencode")]
[InlineData("global", false, true, nameof(AgentEnvironment.OpenCode), "opencode")]
public async Task AgentInit_DefaultsUseDetectionOrCopilotFallback_IndependentOfAssetsAndScope(
string scope, bool configureAspire, bool configureMcp, string? detectedEnvironmentName, string expectedClients)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var homeDirectory = workspace.CreateDirectory("fake-home");
var hooks = new TestTelemetryHookConfigurator();
var interaction = new TestInteractionService();
AgentEnvironment? detectedEnvironment = detectedEnvironmentName is { } name ? Enum.Parse<AgentEnvironment>(name) : null;
var detector = new TestAgentEnvironmentDetector
{
DetectedEnvironments = detectedEnvironment ?? AgentEnvironment.None
};
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliExecutionContextFactory = _ => TestExecutionContextHelper.CreateExecutionContext(workspace.WorkspaceRoot, homeDirectory: homeDirectory);
options.AgentEnvironmentDetectorFactory = _ => detector;
options.TelemetryHookConfiguratorFactory = _ => hooks;
options.InteractionServiceFactory = _ => interaction;
});
using var provider = services.BuildServiceProvider();
var result = provider.GetRequiredService<RootCommand>().Parse(
$"agent init --scope {scope} --skills {(configureAspire ? "aspire" : "none")} --mcp={configureMcp}");
Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
Assert.Equal(1, detector.ScanCount);
var agentPrompt = Assert.Single(interaction.MultiSelectionPromptCalls,
call => call.PromptText == AgentCommandStrings.InitCommand_SelectAgentsPrompt);
Assert.Equal(expectedClients, agentPrompt.DefaultValue);
Assert.Equal(expectedClients.Split(','), agentPrompt.PreSelected.Cast<IAgentClient>().Select(client => client.Id), StringComparers.AgentClientId);
var clients = provider.GetServices<IAgentClient>().ToArray();
var selectedScope = scope == "project" ? AgentConfigurationScope.Project : AgentConfigurationScope.Global;
var defaults = clients.Where(client => expectedClients.Split(',').Contains(client.Id, StringComparers.AgentClientId)).ToArray();
Assert.Equal(
configureMcp
? defaults.Select(client => client.GetMcpConfigPath(selectedScope, workspace.WorkspaceRoot)).Distinct().Order(StringComparer.Ordinal)
: [],
clients.Select(client => client.GetMcpConfigPath(selectedScope, workspace.WorkspaceRoot))
.Distinct().Where(File.Exists).Order(StringComparer.Ordinal));
Assert.Equal(
configureAspire
? defaults.Select(client => client.GetIntegrationConfigPath(selectedScope, workspace.WorkspaceRoot)).Distinct().Order(StringComparer.Ordinal)
: [],
clients.Where(client =>
File.Exists(client.GetIntegrationConfigPath(selectedScope, workspace.WorkspaceRoot)) &&
client.HasIntegrationRegistration(
workspace.WorkspaceRoot, selectedScope, [SkillSource.BuiltIn]))
.Select(client => client.GetIntegrationConfigPath(selectedScope, workspace.WorkspaceRoot))
.Distinct().Order(StringComparer.Ordinal));
var unselectedRoot = scope == "project" ? homeDirectory : workspace.WorkspaceRoot;
Assert.False(Directory.Exists(Path.Combine(unselectedRoot.FullName, ".agents")));
Assert.Equal(
(configureAspire || configureMcp) && selectedScope is AgentConfigurationScope.Global && detectedEnvironment is { } detected &&
defaults.Any(client => (client.Environments & detected) != AgentEnvironment.None) ? detected : AgentEnvironment.None,
hooks.ConfiguredEnvironments);
}
[Theory]
[InlineData("project", false, 0)]
[InlineData("global", false, 0)]
[InlineData("project", true, 0)]
[InlineData("global", true, 1)]
public async Task AgentInit_ExplicitAgents_DetectsOnlyForConsentedGlobalAssets(string scope, bool configureMcp, int expectedScanCount)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var homeDirectory = workspace.CreateDirectory("fake-home");
var detector = new TestAgentEnvironmentDetector { DetectedEnvironments = AgentEnvironment.ClaudeCode };
var hooks = new TestTelemetryHookConfigurator();
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliExecutionContextFactory = _ => TestExecutionContextHelper.CreateExecutionContext(workspace.WorkspaceRoot, homeDirectory: homeDirectory);
options.AgentEnvironmentDetectorFactory = _ => detector;
options.TelemetryHookConfiguratorFactory = _ => hooks;
});
using var provider = services.BuildServiceProvider();
var result = provider.GetRequiredService<RootCommand>().Parse(
$"agent init --agents claude --scope {scope} --skills none --mcp={configureMcp}");
Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
Assert.Equal(expectedScanCount, detector.ScanCount);
Assert.Equal(expectedScanCount == 1 ? AgentEnvironment.ClaudeCode : AgentEnvironment.None, hooks.ConfiguredEnvironments);
}
[Theory]
[InlineData("none", "all", "--mcp")]
[InlineData("all", "none", "--mcp=false")]
public async Task AgentInit_NoneOrAllOff_LeavesSkillsMcpAndHooksUnchanged(string clients, string skills, string mcp)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var homeDirectory = workspace.CreateDirectory("fake-home");
var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".mcp.json");
const string original = """{"mcpServers":{"aspire":{"command":"aspire","args":["mcp","start"]}}}""";
await File.WriteAllTextAsync(configPath, original);
var before = GetFilesWithoutCliLogs(workspace.WorkspaceRoot);
var detector = new TestAgentEnvironmentDetector();
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliExecutionContextFactory = _ => TestExecutionContextHelper.CreateExecutionContext(workspace.WorkspaceRoot, homeDirectory: homeDirectory);
options.AgentEnvironmentDetectorFactory = _ => detector;
});
using var provider = services.BuildServiceProvider();
var result = provider.GetRequiredService<RootCommand>().Parse(
$"agent init --agents {clients} --scope global --skills {skills} {mcp}");
Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
Assert.Equal(0, detector.ScanCount);
Assert.Equal(original, await File.ReadAllTextAsync(configPath));
Assert.Equal(before, GetFilesWithoutCliLogs(workspace.WorkspaceRoot));
Assert.Empty(Directory.GetFileSystemEntries(homeDirectory.FullName));
}
[Theory]
[InlineData("--agents unsupported")]
[InlineData("--agents copilot,unsupported")]
[InlineData("--agents copilot,none")]
[InlineData("--agents \"GitHub Copilot\"")]
[InlineData("--agents \"Claude Code\"")]
[InlineData("--agents")]
[InlineData("--agents \"\"")]
[InlineData("--scope both")]
[InlineData("--scope 0")]
[InlineData("--scope 1")]
[InlineData("--scope user")]
[InlineData("--scope project,global")]
public void AgentInit_InvalidSelection_IsRejectedBeforeExecution(string options)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
using var provider = CliTestHelper.CreateServiceCollection(workspace, outputHelper).BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var before = GetFilesWithoutCliLogs(workspace.WorkspaceRoot);
var result = command.Parse($"agent init {options}");
Assert.Single(result.Errors);
Assert.Equal(before, GetFilesWithoutCliLogs(workspace.WorkspaceRoot));
}
[Theory]
[InlineData("PrOjEcT", nameof(AgentConfigurationScope.Project))]
[InlineData("GlObAl", nameof(AgentConfigurationScope.Global))]
public void AgentInit_ScopeNames_AreCaseInsensitive(string value, string expected)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
using var provider = CliTestHelper.CreateServiceCollection(workspace, outputHelper).BuildServiceProvider();
var result = provider.GetRequiredService<RootCommand>().Parse($"agent init --scope {value}");
Assert.Empty(result.Errors);
Assert.Equal(Enum.Parse<AgentConfigurationScope>(expected), result.GetValue<AgentConfigurationScope>("--scope"));
}
[Theory]
[InlineData("agent init")]
[InlineData("mcp init")]
[InlineData("init")]
[InlineData("new aspire-empty")]
public void AgentSelection_SingularOption_IsRejectedBeforeExecution(string command)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
using var provider = CliTestHelper.CreateServiceCollection(workspace, outputHelper).BuildServiceProvider();
var before = GetFilesWithoutCliLogs(workspace.WorkspaceRoot);
var result = provider.GetRequiredService<RootCommand>().Parse($"{command} --agent copilot");
Assert.NotEmpty(result.Errors);
Assert.Equal(before, GetFilesWithoutCliLogs(workspace.WorkspaceRoot));
}
[Theory]
[InlineData("agent init")]
[InlineData("mcp init")]
[InlineData("init")]
[InlineData("new aspire-empty")]
public void AgentSelection_OptionUsesRegisteredClients(string command)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
services.RemoveAll<IAgentClient>();
services.AddSingleton<IAgentClient, OpenCodeAgentClient>();
using var provider = services.BuildServiceProvider();
var root = provider.GetRequiredService<RootCommand>();
foreach (var selection in new[] { "opencode", "OpEnCoDe", "all", "none" })
{
var result = root.Parse($"{command} --agents {selection}");
Assert.Empty(result.Errors);
Assert.Equal(selection, result.GetValue<string?>("--agents"));
}
var rejected = root.Parse($"{command} --agents copilot");
Assert.Equal(string.Format(CultureInfo.CurrentCulture,
AgentCommandStrings.InitCommand_InvalidEnvironments, "copilot", "opencode", "all", "none"),
Assert.Single(rejected.Errors).Message);
Assert.Equal(["all", "none", "opencode"],
root.Parse($"{command} --agents ").GetCompletions().Select(item => item.Label).Order(StringComparer.Ordinal));
}
[Fact]
public async Task AgentInit_AllUsesOnlyRegisteredClients()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
services.RemoveAll<IAgentClient>();
services.AddSingleton<IAgentClient, OpenCodeAgentClient>();
using var provider = services.BuildServiceProvider();
var before = GetFilesWithoutCliLogs(workspace.WorkspaceRoot);
var result = provider.GetRequiredService<RootCommand>().Parse(
"agent init --agents all --scope project --skills none --mcp");
Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
Assert.Equal(before.Append(Path.Combine(workspace.WorkspaceRoot.FullName, "opencode.jsonc")).Order(StringComparer.Ordinal),
GetFilesWithoutCliLogs(workspace.WorkspaceRoot));
Assert.Equal("opencode", Assert.Single(provider.GetServices<IAgentClient>()).Id);
}
[Fact]
public async Task AgentInit_InteractiveEmptySelection_DoesNotConfigureDefaultClients()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var homeDirectory = workspace.CreateDirectory("fake-home");
var hooks = new TestTelemetryHookConfigurator();
var interaction = new TestInteractionService
{
PromptForSelectionsCallback = (prompt, choices, _, _) =>
{
Assert.Equal(AgentCommandStrings.InitCommand_SelectAgentsPrompt, prompt);
Assert.Equal(["copilot", "claude", "opencode"], choices.Cast<IAgentClient>().Select(client => client.Id), StringComparers.AgentClientId);
return [];
},
ConfirmCallback = (_, _) => throw new InvalidOperationException("Assets must not be prompted for an empty agent selection."),
PromptForSelectionCallback = (_, _, _, _) => throw new InvalidOperationException("Scope must not be prompted for an empty agent selection.")
};
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliExecutionContextFactory = _ => TestExecutionContextHelper.CreateExecutionContext(workspace.WorkspaceRoot, homeDirectory: homeDirectory);
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
options.InteractionServiceFactory = _ => interaction;
options.TelemetryHookConfiguratorFactory = _ => hooks;
});
using var provider = services.BuildServiceProvider();
var before = GetFilesWithoutCliLogs(workspace.WorkspaceRoot);
var result = provider.GetRequiredService<RootCommand>().Parse("agent init --skills all --mcp");
Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
Assert.Equal(before, GetFilesWithoutCliLogs(workspace.WorkspaceRoot));
Assert.Empty(homeDirectory.GetFileSystemInfos());
Assert.Equal(AgentEnvironment.None, hooks.ConfiguredEnvironments);
}
[Theory]
[InlineData("project", true, false)]
[InlineData("project", false, true)]
[InlineData("project", true, true)]
[InlineData("global", true, false)]
[InlineData("global", false, true)]
[InlineData("global", true, true)]
public async Task AgentInit_InstallationLocations_ShowSelectedAssetsAndSharedConsumers(string scope, bool installSkills, bool configureMcp)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
await File.WriteAllTextAsync(Path.Combine(workspace.Path, "apphost.cs"), string.Empty);
var homeDirectory = workspace.CreateDirectory("fake-home");
var labels = new List<string>();
var subtleMessages = new List<string>();
var interaction = new TestInteractionService
{
PromptForSelectionsCallback = (prompt, choices, formatter, _) =>
{
Assert.Equal(AgentCommandStrings.InitCommand_SelectAgentsPrompt, prompt);
labels.AddRange(choices.Cast<object>().Select(formatter));
return choices.Cast<IAgentClient>().Where(client => string.Equals(client.Id, "copilot", StringComparisons.AgentClientId)).Cast<object>().ToArray();
},
DisplaySubtleMessageCallback = subtleMessages.Add
};
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliExecutionContextFactory = _ => TestExecutionContextHelper.CreateExecutionContext(workspace.WorkspaceRoot, homeDirectory: homeDirectory);
options.InteractionServiceFactory = _ => interaction;
});
using var provider = services.BuildServiceProvider();
var result = provider.GetRequiredService<RootCommand>().Parse(
$"agent init --scope {scope} --skills {(installSkills ? "dotnet-inspect" : "none")} --mcp={configureMcp}");
Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
Assert.Empty(subtleMessages);
await Verify(new
{
Agents = labels,
Results = interaction.DisplayedMessages.Select(message => message.Message
.Replace(homeDirectory.FullName, "~", StringComparison.Ordinal)
.Replace(workspace.WorkspaceRoot.FullName, "<workspace>", StringComparison.Ordinal)
.Replace('\\', '/'))
}).UseParameters(scope, installSkills, configureMcp);
}
[Theory]
[InlineData("project", "installed")]
[InlineData("global", "installed")]
[InlineData("project", "skipped")]
[InlineData("project", "failed")]
public async Task AgentInit_SkillSummary_GroupsInstalledSkillsByLocationAndAgent(string scope, string playwrightOutcome)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
await File.WriteAllTextAsync(Path.Combine(workspace.Path, "apphost.cs"), string.Empty);
var homeDirectory = workspace.CreateDirectory("fake-home");
var interaction = new TestInteractionService();
var version = SemVersion.Parse("0.1.3", SemVersionStyles.Strict);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliExecutionContextFactory = _ => TestExecutionContextHelper.CreateExecutionContext(workspace.WorkspaceRoot, homeDirectory: homeDirectory);
options.InteractionServiceFactory = _ => interaction;
options.NpmRunnerFactory = _ => new FakeNpmRunner
{
IsAvailable = playwrightOutcome != "skipped",
ResolvePackageAsyncCallback = (_, _, _) => Task.FromResult<NpmPackageInfo?>(
playwrightOutcome == "installed" ? new NpmPackageInfo { Version = version } : null)
};
options.PlaywrightCliRunnerFactory = _ => new FakePlaywrightCliRunner { InstalledVersion = version };
});
using var provider = services.BuildServiceProvider();
var result = provider.GetRequiredService<RootCommand>().Parse(
$"agent init --agents copilot,claude,opencode --scope {scope} --skills dotnet-inspect,playwright-cli --mcp=false");
Assert.Equal(playwrightOutcome == "failed" ? CliExitCodes.InvalidCommand : CliExitCodes.Success,
await result.InvokeAsync().DefaultTimeout());
var summary = Assert.Single(interaction.DisplayedMessages,
message => message.Message.StartsWith(AgentCommandStrings.InitCommand_InstalledSkillsSummary, StringComparison.Ordinal));
foreach (var client in provider.GetServices<IAgentClient>())
{
var directory = client.GetSkillBaseDirectory(Enum.Parse<AgentConfigurationScope>(scope, ignoreCase: true), workspace.WorkspaceRoot);
Assert.True(File.Exists(Path.Combine(directory, "dotnet-inspect", "SKILL.md")));
Assert.Equal(playwrightOutcome == "installed", File.Exists(Path.Combine(directory, "playwright-cli", "SKILL.md")));
}
if (playwrightOutcome == "installed")
{
var previousCount = interaction.DisplayedMessages.Count;
Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
Assert.Equal(summary, Assert.Single(interaction.DisplayedMessages.Skip(previousCount),
message => message.Message.StartsWith(AgentCommandStrings.InitCommand_InstalledSkillsSummary, StringComparison.Ordinal)));
}
await Verify(summary.Message)
.UseParameters(scope, playwrightOutcome == "installed" ? "installed" : "not-installed")
.DisableRequireUniquePrefix();
}
[Fact]
public async Task AgentInit_SkillSummary_IncludesOnlySuccessfulPlaywrightDestinations()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
await File.WriteAllTextAsync(Path.Combine(workspace.Path, "apphost.cs"), string.Empty);
var homeDirectory = workspace.CreateDirectory("fake-home");
var blockedSkillPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".claude", "skills", "playwright-cli");
await AgentConfigurationTestContext.WriteAsync(blockedSkillPath, "blocked destination");
var interaction = new TestInteractionService();
var version = SemVersion.Parse("0.1.3", SemVersionStyles.Strict);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliExecutionContextFactory = _ => TestExecutionContextHelper.CreateExecutionContext(workspace.WorkspaceRoot, homeDirectory: homeDirectory);
options.InteractionServiceFactory = _ => interaction;
options.NpmRunnerFactory = _ => new FakeNpmRunner
{
ResolvePackageAsyncCallback = (_, _, _) => Task.FromResult<NpmPackageInfo?>(new NpmPackageInfo { Version = version })
};
options.PlaywrightCliRunnerFactory = _ => new FakePlaywrightCliRunner { InstalledVersion = version };
});
using var provider = services.BuildServiceProvider();
var result = provider.GetRequiredService<RootCommand>().Parse(
"agent init --agents copilot,claude,opencode --scope project --skills playwright-cli --mcp=false");
Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
Assert.Empty(interaction.DisplayedErrors);
var warning = Assert.Single(interaction.DisplayedMessages, message => message.Emoji.Equals(KnownEmojis.PoliceCarLight));
Assert.Equal(AgentCommandStrings.PlaywrightCliInstaller_InstalledWithMirrorWarnings, warning.Message);
Assert.Equal("# Playwright CLI Skill", await File.ReadAllTextAsync(
Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills", "playwright-cli", "SKILL.md")));
Assert.Equal("blocked destination", await File.ReadAllTextAsync(blockedSkillPath));
var summary = Assert.Single(interaction.DisplayedMessages,
message => message.Message.StartsWith(AgentCommandStrings.InitCommand_InstalledSkillsSummary, StringComparison.Ordinal));
await Verify(summary.Message);
}
[Fact]
public async Task AgentInit_PromptsForAgentsBeforeAssetsAndScope()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var homeDirectory = workspace.CreateDirectory("fake-home");
// Bound nearest-settings discovery to this workspace.
await File.WriteAllTextAsync(Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.config.json"), "{}");
var skillPath = Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills", "aspire", "SKILL.md");
const string skill = "My customized Aspire instructions.";
await AgentConfigurationTestContext.WriteAsync(skillPath, skill);
var prompts = new List<string>();
var warnings = new List<string>();
var expectedWarnings = new[]
{
string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_SkillSources,
AspireSkillsRemoteConfiguration.RepositoryUrl),
$"[yellow]{SharedCommandStrings.SourceTrustWarning}[/]",
string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_LocalSkillsConflict,
" .agents/skills/aspire/SKILL.md")
};
var interaction = new TestInteractionService
{
DisplayMessageCallback = (emoji, message, _) =>
{
if (emoji.Equals(KnownEmojis.Information) || emoji.Equals(KnownEmojis.PoliceCarLight))
{
warnings.Add(message);
}
},
PromptForSelectionsCallback = (prompt, choices, _, _) =>
{
prompts.Add(prompt);
if (prompt == AgentCommandStrings.InitCommand_SelectAgentsPrompt)
{
return choices.Cast<IAgentClient>().Where(client => string.Equals(client.Id, "copilot", StringComparisons.AgentClientId)).Cast<object>().ToArray();
}
Assert.Equal(AgentCommandStrings.InitCommand_SelectSkills, prompt);
Assert.Equal(expectedWarnings, warnings);
Assert.Equal(["aspire", "playwright-cli"], choices.Cast<string>());
return ["aspire"];
},
ConfirmCallback = (prompt, _) =>
{
prompts.Add(prompt);
return prompt == AgentCommandStrings.InitCommand_ConfigureMcpServerPrompt;
},
PromptForSelectionCallback = (prompt, choices, formatter, _) =>
{
prompts.Add(prompt);
Assert.Equal("Where should skills and tools be installed?", prompt);
Assert.Equal([AgentConfigurationScope.Project, AgentConfigurationScope.Global], choices.Cast<AgentConfigurationScope>());
Assert.Equal(
[
"This project (In your workspace, ready to commit with your project)",
"Global (In your home directory, available across all projects)"
],
choices.Cast<object>().Select(formatter));
Assert.Equal(expectedWarnings, warnings);
Assert.Empty(homeDirectory.GetFileSystemInfos());
return AgentConfigurationScope.Global;
}
};
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliExecutionContextFactory = _ => TestExecutionContextHelper.CreateExecutionContext(workspace.WorkspaceRoot, homeDirectory: homeDirectory);
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
options.InteractionServiceFactory = _ => interaction;
options.ProjectLocatorFactory = _ => new TestProjectLocator();
});
using var provider = services.BuildServiceProvider();
var result = provider.GetRequiredService<RootCommand>().Parse("agent init");
Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
Assert.Equal(
[AgentCommandStrings.InitCommand_SelectAgentsPrompt, AgentCommandStrings.InitCommand_SelectSkills,
AgentCommandStrings.InitCommand_ConfigureMcpServerPrompt, AgentCommandStrings.InitCommand_SelectScopePrompt],
prompts);
Assert.Equal(expectedWarnings, warnings);
Assert.Equal(skill, await File.ReadAllTextAsync(skillPath));
var copilot = provider.GetServices<IAgentClient>().Single(client =>
string.Equals(client.Id, "copilot", StringComparisons.AgentClientId));
Assert.True(copilot.HasIntegrationRegistration(
workspace.WorkspaceRoot, AgentConfigurationScope.Global, [SkillSource.BuiltIn]));
Assert.True(File.Exists(Path.Combine(homeDirectory.FullName, ".copilot", "mcp-config.json")));
}
[Theory]
[InlineData(false, false)]
[InlineData(false, true)]
[InlineData(true, false)]
[InlineData(true, true)]
public async Task AgentInit_AspireConfigurationErrors_OnlyBlockAspireSelection(bool invalidSource, bool configureAspire)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var homeDirectory = workspace.CreateDirectory("fake-home");
await File.WriteAllTextAsync(Path.Combine(workspace.WorkspaceRoot.FullName, "aspire.config.json"),
invalidSource ? """{"skillSources":{"default":{"repository":""}}}""" : "{}");
if (!invalidSource)
{
// A directory at SKILL.md makes the scan fail on every platform.
Directory.CreateDirectory(Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills", "aspire", "SKILL.md"));
}
var skillsPrompted = false;
var interaction = new TestInteractionService
{
PromptForSelectionsCallback = (prompt, choices, _, _) =>
{
if (prompt == AgentCommandStrings.InitCommand_SelectAgentsPrompt)
{
return choices.Cast<IAgentClient>().Where(client => string.Equals(client.Id, "copilot", StringComparisons.AgentClientId)).Cast<object>().ToArray();
}
Assert.Equal(AgentCommandStrings.InitCommand_SelectSkills, prompt);
skillsPrompted = true;
return configureAspire ? ["aspire"] : [];
},
ConfirmCallback = (prompt, _) => prompt == AgentCommandStrings.InitCommand_ConfigureMcpServerPrompt,
PromptForSelectionCallback = (_, _, _, _) => AgentConfigurationScope.Global
};
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliExecutionContextFactory = _ => TestExecutionContextHelper.CreateExecutionContext(workspace.WorkspaceRoot, homeDirectory: homeDirectory);
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
options.InteractionServiceFactory = _ => interaction;
options.ProjectLocatorFactory = _ => new TestProjectLocator();
});
using var provider = services.BuildServiceProvider();
var result = provider.GetRequiredService<RootCommand>().Parse("agent init");
Assert.Equal(configureAspire ? CliExitCodes.InvalidCommand : CliExitCodes.Success,
await result.InvokeAsync().DefaultTimeout());
Assert.True(skillsPrompted);
Assert.Equal(!configureAspire, File.Exists(Path.Combine(homeDirectory.FullName, ".copilot", "mcp-config.json")));
if (configureAspire)
{
Assert.Single(interaction.DisplayedErrors);
}
else
{
Assert.Empty(interaction.DisplayedErrors);
}
}
[Fact]
public async Task AgentInit_NoSelectedAssets_SkipsScopeAndWrites()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var homeDirectory = workspace.CreateDirectory("fake-home");
var interaction = new TestInteractionService
{
PromptForSelectionsCallback = (prompt, choices, _, _) =>
{
return prompt == AgentCommandStrings.InitCommand_SelectAgentsPrompt
? choices.Cast<IAgentClient>().Where(client => string.Equals(client.Id, "copilot", StringComparisons.AgentClientId)).Cast<object>().ToArray() : [];
},
PromptForSelectionCallback = (_, _, _, _) => throw new InvalidOperationException("Scope must not be prompted without selected assets.")
};
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliExecutionContextFactory = _ => TestExecutionContextHelper.CreateExecutionContext(workspace.WorkspaceRoot, homeDirectory: homeDirectory);
options.CliHostEnvironmentFactory = _ => TestHelpers.CreateInteractiveHostEnvironment();
options.InteractionServiceFactory = _ => interaction;
});
using var provider = services.BuildServiceProvider();
var before = GetFilesWithoutCliLogs(workspace.WorkspaceRoot);
var result = provider.GetRequiredService<RootCommand>().Parse("agent init");
Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
Assert.Equal(before, GetFilesWithoutCliLogs(workspace.WorkspaceRoot));
Assert.Empty(homeDirectory.GetFileSystemInfos());
}
[Fact]
public async Task AgentInit_DuplicateClients_ConfigureEachIntegrationOnce()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
using var provider = CliTestHelper.CreateServiceCollection(workspace, outputHelper).BuildServiceProvider();
var result = provider.GetRequiredService<RootCommand>().Parse(
"agent init --agents COPILOT,copilot,OpenCode --scope project --skills aspire --mcp=false");
Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
foreach (var client in provider.GetServices<IAgentClient>()
.Where(client => client.Id is "copilot" or "opencode"))
{
Assert.True(client.HasIntegrationRegistration(
workspace.WorkspaceRoot, AgentConfigurationScope.Project, [SkillSource.BuiltIn]));
}
}
[Theory]
[InlineData("project")]
[InlineData("global")]
public async Task AgentInit_ExplicitMcp_UsesSelectedScopeAndSharedProjectFile(string scope)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var homeDirectory = workspace.CreateDirectory("fake-home");
var executionContext = TestExecutionContextHelper.CreateExecutionContext(workspace.WorkspaceRoot, homeDirectory: homeDirectory);
var environment = new TestEnvironment();
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliExecutionContextFactory = _ => executionContext;
});
services.AddSingleton<IEnvironment>(environment);
using var provider = services.BuildServiceProvider();
var before = GetFilesWithoutCliLogs(workspace.WorkspaceRoot);
var result = provider.GetRequiredService<RootCommand>().Parse($"agent init --agents all --scope {scope} --skills none --mcp");
Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
var selectedScope = scope == "project" ? AgentConfigurationScope.Project : AgentConfigurationScope.Global;
var paths = provider.GetServices<IAgentClient>().Select(client => client.GetMcpConfigPath(selectedScope, workspace.WorkspaceRoot)).Distinct().ToArray();
var root = selectedScope is AgentConfigurationScope.Project ? workspace.WorkspaceRoot : homeDirectory;
var contents = new Dictionary<string, JsonElement>();
foreach (var path in paths)
{
contents.Add(Path.GetRelativePath(root.FullName, path).Replace('\\', '/'), JsonSerializer.Deserialize<JsonElement>(await File.ReadAllTextAsync(path)));
}
Assert.Equal(before.Concat(paths).Order(StringComparer.Ordinal), GetFilesWithoutCliLogs(workspace.WorkspaceRoot));
await Verify(JsonSerializer.Serialize(contents), "json").UseParameters(scope);
}
[Fact]
public async Task AgentInit_ExistingPinnedMcp_IsPreserved()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var path = Path.Combine(workspace.WorkspaceRoot.FullName, ".mcp.json");
const string pinned = """{ "mcpServers": { "aspire": { "command": "custom-aspire", "args": ["--pinned"] } }, "preference": true }""";
await File.WriteAllTextAsync(path, pinned);
using var provider = CliTestHelper.CreateServiceCollection(workspace, outputHelper).BuildServiceProvider();
var result = provider.GetRequiredService<RootCommand>().Parse("agent init --agents copilot,claude --skills none --mcp");
Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
Assert.Equal(pinned, await File.ReadAllTextAsync(path));
}
[Theory]
[InlineData("copilot", "project", true)]
[InlineData("claude", "project", false)]
[InlineData("copilot", "global", false)]
public async Task AgentInit_LegacyMcpRepairs_AreLimitedToSelectedClientAndScope(string client, string scope, bool shouldRepair)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var configDirectory = workspace.CreateDirectory(".vscode");
var configPath = Path.Combine(configDirectory.FullName, "mcp.json");
const string original = """{"servers":{"aspire":{"command":"aspire","args":["mcp","start"]}}}""";
await File.WriteAllTextAsync(configPath, original);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
using var provider = services.BuildServiceProvider();
var result = provider.GetRequiredService<RootCommand>().Parse($"agent init --agents {client} --scope {scope} --skills none --mcp");
Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
using var config = JsonDocument.Parse(await File.ReadAllTextAsync(configPath));
Assert.Equal(shouldRepair ? ["agent", "mcp"] : ["mcp", "start"],
config.RootElement.GetProperty("servers").GetProperty("aspire").GetProperty("args").EnumerateArray().Select(argument => argument.GetString()));
if (!shouldRepair)
{
Assert.Equal(original, await File.ReadAllTextAsync(configPath));
}
}
[Theory]
[InlineData("""
{
// Keep the editor's existing server and formatting.
"servers": {
"other": { "command": "other", "args": ["https://example.test/a//b"] }
}
}
""")]
[InlineData("""{"servers":{"other":{"command":"other","args":["--custom",],},},}""")]
public async Task AgentInit_CopilotProjectMcp_AcceptsJsoncWithoutDeprecatedEntries(string original)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var configDirectory = workspace.CreateDirectory(".vscode");
var configPath = Path.Combine(configDirectory.FullName, "mcp.json");
await File.WriteAllTextAsync(configPath, original);
using var provider = CliTestHelper.CreateServiceCollection(workspace, outputHelper).BuildServiceProvider();
var result = provider.GetRequiredService<RootCommand>().Parse(
"agent init --agents copilot --scope project --skills none --mcp");
Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
Assert.Equal(original, await File.ReadAllTextAsync(configPath));
}
[Theory]
[InlineData("copilot", "GitHub Copilot", ".mcp.json")]
[InlineData("claude", "Claude Code", ".mcp.json")]
[InlineData("opencode", "OpenCode", "opencode.jsonc")]
public async Task AgentInit_AgentIds_UseLocalizedConfigurationDescriptions(string client, string displayName, string configFileName)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var interaction = new TestInteractionService();
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.InteractionServiceFactory = _ => interaction;
});
using var provider = services.BuildServiceProvider();
var command = provider.GetRequiredService<RootCommand>();
var result = command.Parse($"agent init --agents {client} --skills none --mcp");
Assert.Empty(result.Errors);
Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, configFileName);
Assert.True(File.Exists(configPath));
Assert.Equal(
string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_McpConfigurationDescription, displayName, configPath),
Assert.Single(interaction.DisplayedMessages).Message);
}
[Theory]
[InlineData("claude", "CLAUDE_CONFIG_DIR", "custom-claude", "custom-claude/.claude.json")]
[InlineData("opencode", "OPENCODE_CONFIG", "custom-opencode.json", "custom-opencode.json")]
[InlineData("opencode", "OPENCODE_CONFIG_DIR", "custom-opencode", "custom-opencode/opencode.jsonc")]
[InlineData("opencode", "XDG_CONFIG_HOME", "custom-xdg", "custom-xdg/opencode/opencode.jsonc")]
public async Task AgentInit_GlobalMcpOverrides_WriteOnlyTheResolvedDestination(
string client, string variable, string value, string expectedRelativePath)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var homeDirectory = workspace.CreateDirectory("fake-home");
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliExecutionContextFactory = _ => TestExecutionContextHelper.CreateExecutionContext(workspace.WorkspaceRoot, homeDirectory: homeDirectory);
});
services.AddSingleton<IEnvironment>(new TestEnvironment(new Dictionary<string, string?> { [variable] = value }));
using var provider = services.BuildServiceProvider();
var before = GetFilesWithoutCliLogs(workspace.WorkspaceRoot);
var result = provider.GetRequiredService<RootCommand>().Parse(
$"agent init --agents {client} --scope global --skills none --mcp");
Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
var expectedPath = Path.Combine(workspace.WorkspaceRoot.FullName, expectedRelativePath.Replace('/', Path.DirectorySeparatorChar));
Assert.Equal(before.Append(expectedPath).Order(StringComparer.Ordinal), GetFilesWithoutCliLogs(workspace.WorkspaceRoot));
Assert.Empty(homeDirectory.GetFileSystemInfos());
}
[Theory]
[InlineData("project", false, false, "opencode.jsonc")]
[InlineData("project", true, false, "opencode.json")]
[InlineData("project", true, true, "opencode.jsonc")]
[InlineData("global", false, false, "opencode.jsonc")]
[InlineData("global", true, false, "opencode.json")]
[InlineData("global", true, true, "opencode.jsonc")]
public async Task AgentInit_OpenCodeConfiguration_UsesExistingFormatAndPrecedence(
string scopeName, bool jsonExists, bool jsoncExists, string expectedFileName)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var homeDirectory = workspace.CreateDirectory("fake-home");
var environment = new TestEnvironment();
var executionContext = TestExecutionContextHelper.CreateExecutionContext(workspace.WorkspaceRoot, homeDirectory: homeDirectory);
var scope = scopeName == "project" ? AgentConfigurationScope.Project : AgentConfigurationScope.Global;
var configDirectory = new DirectoryInfo(Path.GetDirectoryName(
scope is AgentConfigurationScope.Project ? OpenCodePaths.GetMcpConfigPath(workspace.WorkspaceRoot)
: OpenCodePaths.GetGlobalMcpConfigPath(executionContext, environment))!);
configDirectory.Create();
if (jsonExists)
{
await File.WriteAllTextAsync(Path.Combine(configDirectory.FullName, "opencode.json"), """{"model":"keep-json","mcp":{"other":{"command":["other"]}}}""");
}
if (jsoncExists)
{
await File.WriteAllTextAsync(Path.Combine(configDirectory.FullName, "opencode.jsonc"), """{"model":"keep-jsonc","mcp":{"other":{"command":["other"]}}}""");
}
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliExecutionContextFactory = _ => executionContext;
});
services.AddSingleton<IEnvironment>(environment);
using var provider = services.BuildServiceProvider();
var before = GetFilesWithoutCliLogs(workspace.WorkspaceRoot);
var expectedPath = Path.Combine(configDirectory.FullName, expectedFileName);
var result = provider.GetRequiredService<RootCommand>().Parse(
$"agent init --agents opencode --scope {scopeName} --skills none --mcp");
Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
var openCode = provider.GetServices<IAgentClient>().Single(client => string.Equals(client.Id, "opencode", StringComparisons.AgentClientId));
Assert.Equal(expectedPath, openCode.GetMcpConfigPath(scope, workspace.WorkspaceRoot));
Assert.Equal(before.Append(expectedPath).Distinct().Order(StringComparer.Ordinal), GetFilesWithoutCliLogs(workspace.WorkspaceRoot));
var configurations = new SortedDictionary<string, string>(StringComparer.Ordinal);
foreach (var path in Directory.EnumerateFiles(configDirectory.FullName, "opencode.json*"))
{
configurations.Add(Path.GetFileName(path), await File.ReadAllTextAsync(path));
}
await Verify(configurations).UseParameters(scopeName, jsonExists, jsoncExists);
}
[Theory]
[InlineData("project", false, false)]
[InlineData("project", true, true)]
[InlineData("project", true, false)]
[InlineData("global", false, false)]
[InlineData("global", true, true)]
[InlineData("global", true, false)]
public async Task AgentInit_OpenCodeConfiguration_PreservesPinnedServers(string scopeName, bool jsoncExists, bool jsoncPinned)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var homeDirectory = workspace.CreateDirectory("fake-home");
var environment = new TestEnvironment();
var executionContext = TestExecutionContextHelper.CreateExecutionContext(workspace.WorkspaceRoot, homeDirectory: homeDirectory);
var scope = scopeName == "project" ? AgentConfigurationScope.Project : AgentConfigurationScope.Global;
var configDirectory = Path.GetDirectoryName(scope is AgentConfigurationScope.Project
? OpenCodePaths.GetMcpConfigPath(workspace.WorkspaceRoot) : OpenCodePaths.GetGlobalMcpConfigPath(executionContext, environment))!;
Directory.CreateDirectory(configDirectory);
var originals = new Dictionary<string, string>();
foreach (var fileName in jsoncExists ? new[] { "opencode.json", "opencode.jsonc" } : ["opencode.json"])
{
var path = Path.Combine(configDirectory, fileName);
var original = fileName switch
{
"opencode.json" when jsoncPinned => """{"mcp":{"aspire":{"command":["aspire","mcp","start"]}}}""",
"opencode.jsonc" when !jsoncPinned => """
{
// Partial overrides must not shadow the Aspire command pin in opencode.json.
"model": "keep-jsonc",
"mcp": { "aspire": { "enabled": true }, "other": { "command": ["other"], }, },
}
""",
_ => $$"""
{
"mcp": { "aspire": { "command": ["pinned-{{fileName}}"], "enabled": false } }
}
"""
};
originals.Add(path, original);
await File.WriteAllTextAsync(path, original);
}
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliExecutionContextFactory = _ => executionContext;
});
services.AddSingleton<IEnvironment>(environment);
using var provider = services.BuildServiceProvider();
var before = GetFilesWithoutCliLogs(workspace.WorkspaceRoot);
var result = provider.GetRequiredService<RootCommand>().Parse(
$"agent init --agents opencode --scope {scopeName} --skills none --mcp");
Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
foreach (var (path, original) in originals)
{
Assert.Equal(original, await File.ReadAllTextAsync(path));
}
Assert.Equal(before, GetFilesWithoutCliLogs(workspace.WorkspaceRoot));
}
[Theory]
[InlineData("project")]
[InlineData("global")]
public async Task AgentInit_OpenCodeRemoteJsonc_DoesNotRepairUnusedSiblingCommand(string scopeName)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var homeDirectory = workspace.CreateDirectory("fake-home");
var environment = new TestEnvironment();
var executionContext = TestExecutionContextHelper.CreateExecutionContext(workspace.WorkspaceRoot, homeDirectory: homeDirectory);
var scope = scopeName == "project" ? AgentConfigurationScope.Project : AgentConfigurationScope.Global;
var jsoncPath = scope is AgentConfigurationScope.Project ? OpenCodePaths.GetMcpConfigPath(workspace.WorkspaceRoot)
: OpenCodePaths.GetGlobalMcpConfigPath(executionContext, environment);
var jsonPath = Path.ChangeExtension(jsoncPath, ".json");
const string original = """{"mcp":{"aspire":{"type":"local","command":["aspire","mcp","start"],"enabled":true}}}""";
const string jsonc = """{"mcp":{"aspire":{"type":"remote","url":"https://example.test/mcp","enabled":true}}}""";
Directory.CreateDirectory(Path.GetDirectoryName(jsoncPath)!);
await File.WriteAllTextAsync(jsonPath, original);
await File.WriteAllTextAsync(jsoncPath, jsonc);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliExecutionContextFactory = _ => executionContext;
});
services.AddSingleton<IEnvironment>(environment);
using var provider = services.BuildServiceProvider();
var result = provider.GetRequiredService<RootCommand>().Parse(
$"agent init --agents opencode --scope {scopeName} --skills none --mcp");
Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
Assert.Equal(original, await File.ReadAllTextAsync(jsonPath));
Assert.Equal(jsonc, await File.ReadAllTextAsync(jsoncPath));
}
[Fact]
public async Task AgentInit_OpenCodeExplicitConfigFile_DoesNotUseSiblingJsonPin()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var jsonPath = Path.Combine(workspace.WorkspaceRoot.FullName, "opencode.json");
const string pinned = """{"mcp":{"aspire":{"command":["pinned-aspire"]}}}""";
await File.WriteAllTextAsync(jsonPath, pinned);
var interaction = new TestInteractionService();
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.InteractionServiceFactory = _ => interaction;
});
services.AddSingleton<IEnvironment>(new TestEnvironment(
new Dictionary<string, string?> { ["OPENCODE_CONFIG"] = "opencode.jsonc" }));
using var provider = services.BuildServiceProvider();
var before = GetFilesWithoutCliLogs(workspace.WorkspaceRoot);
var jsoncPath = Path.Combine(workspace.WorkspaceRoot.FullName, "opencode.jsonc");
var result = provider.GetRequiredService<RootCommand>().Parse(
"agent init --agents opencode --scope global --skills none --mcp");
Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
Assert.Equal(pinned, await File.ReadAllTextAsync(jsonPath));
Assert.Equal(before.Append(jsoncPath).Order(StringComparer.Ordinal), GetFilesWithoutCliLogs(workspace.WorkspaceRoot));
Assert.Equal(
string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_McpConfigurationDescription,
AgentCommandStrings.Environment_OpenCode, jsoncPath),
Assert.Single(interaction.DisplayedMessages).Message);
}
[Theory]
[InlineData("project", false)]
[InlineData("global", false)]
[InlineData("project", true)]
[InlineData("global", true)]
public async Task AgentInit_OpenCodeSiblingJson_RepairsInheritedDeprecatedCommand(string scopeName, bool partialOverride)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var homeDirectory = workspace.CreateDirectory("fake-home");
var environment = new TestEnvironment();
var executionContext = TestExecutionContextHelper.CreateExecutionContext(workspace.WorkspaceRoot, homeDirectory: homeDirectory);
var scope = scopeName == "project" ? AgentConfigurationScope.Project : AgentConfigurationScope.Global;
var jsoncPath = scope is AgentConfigurationScope.Project ? OpenCodePaths.GetMcpConfigPath(workspace.WorkspaceRoot)
: OpenCodePaths.GetGlobalMcpConfigPath(executionContext, environment);
var jsonPath = Path.ChangeExtension(jsoncPath, ".json");
const string original = """{"preference":true,"mcp":{"aspire":{"type":"local","command":["aspire","mcp","start"],"enabled":false},"other":{"command":["other"]}}}""";
var jsonc = partialOverride
? """
{
// Override settings, but inherit the command from opencode.json.
"model": "keep-jsonc",
"mcp": { "aspire": { "enabled": true, "environment": { "CUSTOM": "keep" }, }, "other": { "command": ["override-other"], }, },
}
"""
: """
{
// Higher-precedence settings must not acquire a shadowing Aspire entry.
"model": "keep-jsonc",
"mcp": { "other": { "command": ["override-other"], }, },
}
""";
Directory.CreateDirectory(Path.GetDirectoryName(jsoncPath)!);
await File.WriteAllTextAsync(jsonPath, original);
await File.WriteAllTextAsync(jsoncPath, jsonc);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliExecutionContextFactory = _ => executionContext;
});
services.AddSingleton<IEnvironment>(environment);
using var provider = services.BuildServiceProvider();
var before = GetFilesWithoutCliLogs(workspace.WorkspaceRoot);
var result = provider.GetRequiredService<RootCommand>().Parse(
$"agent init --agents opencode --scope {scopeName} --skills none --mcp");
Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
var updated = await File.ReadAllTextAsync(jsonPath);
Assert.Equal(jsonc, await File.ReadAllTextAsync(jsoncPath));
Assert.Equal(before, GetFilesWithoutCliLogs(workspace.WorkspaceRoot));
await Verify(updated, "json").UseParameters(scopeName, partialOverride);
}
[Theory]
[InlineData("project", "{ malformed")]
[InlineData("global", "{ malformed")]
[InlineData("project", """{"mcp":[]}""")]
[InlineData("global", """{"mcp":[]}""")]
public async Task AgentInit_OpenCodeInvalidSiblingJson_FailsWithoutWriting(string scopeName, string malformed)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var homeDirectory = workspace.CreateDirectory("fake-home");
var environment = new TestEnvironment();
var executionContext = TestExecutionContextHelper.CreateExecutionContext(workspace.WorkspaceRoot, homeDirectory: homeDirectory);
var scope = scopeName == "project" ? AgentConfigurationScope.Project : AgentConfigurationScope.Global;
var jsoncPath = scope is AgentConfigurationScope.Project ? OpenCodePaths.GetMcpConfigPath(workspace.WorkspaceRoot)
: OpenCodePaths.GetGlobalMcpConfigPath(executionContext, environment);
var jsonPath = Path.ChangeExtension(jsoncPath, ".json");
const string original = """{"model":"keep-jsonc"}""";
Directory.CreateDirectory(Path.GetDirectoryName(jsoncPath)!);
await File.WriteAllTextAsync(jsoncPath, original);
await File.WriteAllTextAsync(jsonPath, malformed);
var interaction = new TestInteractionService();
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliExecutionContextFactory = _ => executionContext;
options.InteractionServiceFactory = _ => interaction;
});
services.AddSingleton<IEnvironment>(environment);
using var provider = services.BuildServiceProvider();
var before = GetFilesWithoutCliLogs(workspace.WorkspaceRoot);
var result = provider.GetRequiredService<RootCommand>().Parse(
$"agent init --agents opencode --scope {scopeName} --skills none --mcp");
Assert.Equal(CliExitCodes.InvalidCommand, await result.InvokeAsync().DefaultTimeout());
Assert.Contains(jsonPath, Assert.Single(interaction.DisplayedErrors));
Assert.Equal(original, await File.ReadAllTextAsync(jsoncPath));
Assert.Equal(malformed, await File.ReadAllTextAsync(jsonPath));
Assert.Equal(before, GetFilesWithoutCliLogs(workspace.WorkspaceRoot));
}
[Theory]
[InlineData("OPENCODE_CONFIG_DIR", "custom-opencode", "custom-opencode")]
[InlineData("XDG_CONFIG_HOME", "custom-xdg", "custom-xdg/opencode")]
public async Task AgentInit_OpenCodeGlobalDirectoryOverrides_UseExistingJsonConfiguration(
string variable, string value, string relativeDirectory)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var configDirectory = workspace.CreateDirectory(relativeDirectory.Replace('/', Path.DirectorySeparatorChar));
var configPath = Path.Combine(configDirectory.FullName, "opencode.json");
const string pinned = """{"mcp":{"aspire":{"command":["pinned-aspire"]}}}""";
await File.WriteAllTextAsync(configPath, pinned);
var environment = new TestEnvironment(new Dictionary<string, string?> { [variable] = value });
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
services.AddSingleton<IEnvironment>(environment);
using var provider = services.BuildServiceProvider();
var before = GetFilesWithoutCliLogs(workspace.WorkspaceRoot);
var result = provider.GetRequiredService<RootCommand>().Parse("agent init --agents opencode --scope global --skills none --mcp");
Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
Assert.Equal(pinned, await File.ReadAllTextAsync(configPath));
Assert.Equal(before, GetFilesWithoutCliLogs(workspace.WorkspaceRoot));
}
[Fact]
public async Task AgentInit_PinnedGlobalMcpOverrides_ArePreserved()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var copilot = workspace.CreateDirectory("custom-copilot");
var claude = workspace.CreateDirectory("custom-claude");
var openCode = workspace.CreateDirectory("custom-opencode");
var environment = new TestEnvironment(new Dictionary<string, string?>
{
["COPILOT_HOME"] = copilot.FullName,
["CLAUDE_CONFIG_DIR"] = claude.FullName,
["OPENCODE_CONFIG"] = Path.Combine(openCode.FullName, "custom.json")
});
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper);
services.AddSingleton<IEnvironment>(environment);
using var provider = services.BuildServiceProvider();
var originals = new Dictionary<string, string>();
foreach (var client in provider.GetServices<IAgentClient>())
{
var path = client.GetMcpConfigPath(AgentConfigurationScope.Global, workspace.WorkspaceRoot);
var content = string.Equals(client.Id, "opencode", StringComparisons.AgentClientId)
? """{ "mcp": { "aspire": { "command": ["pinned-aspire"] } }, "preference": true }"""
: """{ "mcpServers": { "aspire": { "command": "pinned-aspire" } }, "preference": true }""";
originals.Add(path, content);
await File.WriteAllTextAsync(path, content);
}
var before = GetFilesWithoutCliLogs(workspace.WorkspaceRoot);
var result = provider.GetRequiredService<RootCommand>().Parse("agent init --agents all --scope global --skills none --mcp");
Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
foreach (var (path, content) in originals)
{
Assert.Equal(content, await File.ReadAllTextAsync(path));
}
Assert.Equal(before, GetFilesWithoutCliLogs(workspace.WorkspaceRoot));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task AgentInit_AliasedMcpDestinations_ConfigureEachSelectedServerContainer(bool pinned)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var homeDirectory = workspace.CreateDirectory("fake-home");
var executionContext = TestExecutionContextHelper.CreateExecutionContext(workspace.WorkspaceRoot, homeDirectory: homeDirectory);
var path = Path.Combine(homeDirectory.FullName, ".copilot", "mcp-config.json");
var environment = new TestEnvironment(new Dictionary<string, string?> { ["OPENCODE_CONFIG"] = path });
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
var original = pinned
? """{"preference":true,"mcpServers":{"aspire":{"command":"pinned-aspire","args":["--pinned"]}}}"""
: """{"preference":true}""";
await File.WriteAllTextAsync(path, original);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliExecutionContextFactory = _ => executionContext;
});
services.AddSingleton<IEnvironment>(environment);
using var provider = services.BuildServiceProvider();
var before = GetFilesWithoutCliLogs(workspace.WorkspaceRoot);
var result = provider.GetRequiredService<RootCommand>().Parse(
"agent init --agents copilot,opencode --scope global --skills none --mcp");
Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
Assert.Equal(before, GetFilesWithoutCliLogs(workspace.WorkspaceRoot));
await Verify(await File.ReadAllTextAsync(path), "json").UseParameters(pinned);
}
[Fact]
public async Task AgentInit_AliasedMalformedMcpContainer_FailsWithoutWriting()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var homeDirectory = workspace.CreateDirectory("fake-home");
var executionContext = TestExecutionContextHelper.CreateExecutionContext(workspace.WorkspaceRoot, homeDirectory: homeDirectory);
var path = Path.Combine(homeDirectory.FullName, ".copilot", "mcp-config.json");
var environment = new TestEnvironment(new Dictionary<string, string?> { ["OPENCODE_CONFIG"] = path });
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
const string original = """{ "preference": true, "mcp": [] }""";
await File.WriteAllTextAsync(path, original);
var interaction = new TestInteractionService();
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliExecutionContextFactory = _ => executionContext;
options.InteractionServiceFactory = _ => interaction;
});
services.AddSingleton<IEnvironment>(environment);
using var provider = services.BuildServiceProvider();
var before = GetFilesWithoutCliLogs(workspace.WorkspaceRoot);
var result = provider.GetRequiredService<RootCommand>().Parse(
"agent init --agents copilot,opencode --scope global --skills none --mcp");
Assert.Equal(CliExitCodes.InvalidCommand, await result.InvokeAsync().DefaultTimeout());
Assert.Contains(path, Assert.Single(interaction.DisplayedErrors));
Assert.Equal(original, await File.ReadAllTextAsync(path));
Assert.Equal(before, GetFilesWithoutCliLogs(workspace.WorkspaceRoot));
}
[Theory]
[MemberData(nameof(MalformedMcpConfigurations))]
public async Task AgentInit_MalformedSelectedMcp_FailsWithoutOverwriting(string clientId, string scopeName, string original)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var homeDirectory = workspace.CreateDirectory("fake-home");
var environment = new TestEnvironment();
var executionContext = TestExecutionContextHelper.CreateExecutionContext(workspace.WorkspaceRoot, homeDirectory: homeDirectory);
var interaction = new TestInteractionService();
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliExecutionContextFactory = _ => executionContext;
options.InteractionServiceFactory = _ => interaction;
});
services.AddSingleton<IEnvironment>(environment);
using var provider = services.BuildServiceProvider();
var client = provider.GetServices<IAgentClient>().Single(client => string.Equals(client.Id, clientId, StringComparisons.AgentClientId));
var scope = scopeName == "project" ? AgentConfigurationScope.Project : AgentConfigurationScope.Global;
var path = client.GetMcpConfigPath(scope, workspace.WorkspaceRoot);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await File.WriteAllTextAsync(path, original);
var before = GetFilesWithoutCliLogs(workspace.WorkspaceRoot);
var result = provider.GetRequiredService<RootCommand>().Parse(
$"agent init --agents {clientId} --scope {scopeName} --skills none --mcp");
Assert.Equal(CliExitCodes.InvalidCommand, await result.InvokeAsync().DefaultTimeout());
Assert.Contains(path, Assert.Single(interaction.DisplayedErrors));
Assert.Equal(original, await File.ReadAllTextAsync(path));
Assert.Equal(before, GetFilesWithoutCliLogs(workspace.WorkspaceRoot));
}
public static IEnumerable<object[]> MalformedMcpConfigurations()
{
foreach (var client in new[] { "copilot", "claude", "opencode" })
{
foreach (var scope in new[] { "project", "global" })
{
foreach (var content in new[] { "{ malformed", "", "[]", "null" })
{
yield return [client, scope, content];
}
}
}
yield return ["copilot", "project", """{"mcpServers":[]}"""];
yield return ["opencode", "global", """{"mcp":"custom"}"""];
}
[Theory]
[InlineData("copilot", "project")]
[InlineData("claude", "project")]
[InlineData("opencode", "project")]
[InlineData("copilot", "global")]
[InlineData("claude", "global")]
[InlineData("opencode", "global")]
public async Task AgentInit_DeprecatedMcpCommand_RepairsSelectedConfigurationWithoutReplacingOtherSettings(string clientId, string scopeName)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var homeDirectory = workspace.CreateDirectory("fake-home");
var environment = new TestEnvironment();
var executionContext = TestExecutionContextHelper.CreateExecutionContext(workspace.WorkspaceRoot, homeDirectory: homeDirectory);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliExecutionContextFactory = _ => executionContext;
});
services.AddSingleton<IEnvironment>(environment);
using var provider = services.BuildServiceProvider();
var client = provider.GetServices<IAgentClient>().Single(client => string.Equals(client.Id, clientId, StringComparisons.AgentClientId));
var scope = scopeName == "project" ? AgentConfigurationScope.Project : AgentConfigurationScope.Global;
var path = client.GetMcpConfigPath(scope, workspace.WorkspaceRoot);
var original = clientId == "opencode"
? """{"preference":true,"mcp":{"aspire":{"type":"local","command":["aspire","mcp","start"],"enabled":false},"other":{"command":["other"]}}}"""
: """{"preference":true,"mcpServers":{"aspire":{"command":"aspire","args":["mcp","start"],"env":{"CUSTOM":"keep"}},"other":{"command":"other"}}}""";
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await File.WriteAllTextAsync(path, original);
var result = provider.GetRequiredService<RootCommand>().Parse(
$"agent init --agents {clientId} --scope {scopeName} --skills none --mcp");
Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
await Verify(await File.ReadAllTextAsync(path), "json").UseParameters(clientId, scopeName);
}
[Theory]
[InlineData("copilot", "global", """{"command":"aspire","args":["mcp","start","--custom"],"env":{"CUSTOM":"keep"}}""")]
[InlineData("opencode", "project", """{"type":"local","command":["aspire","mcp","start","--custom"],"enabled":false}""")]
[InlineData("copilot", "project", """{"command":"aspire","args":["mcp",42],"env":{"CUSTOM":"keep"}}""")]
[InlineData("opencode", "global", """{"type":"local","command":["aspire",{},"start"],"enabled":false}""")]
[InlineData("copilot", "project", """{"command":"custom-aspire","args":["mcp","start"],"env":{"CUSTOM":"keep"}}""")]
public async Task AgentInit_CustomizedDeprecatedMcpCommand_IsPreserved(string clientId, string scopeName, string serverConfiguration)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var homeDirectory = workspace.CreateDirectory("fake-home");
var environment = new TestEnvironment();
var executionContext = TestExecutionContextHelper.CreateExecutionContext(workspace.WorkspaceRoot, homeDirectory: homeDirectory);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliExecutionContextFactory = _ => executionContext;
});
services.AddSingleton<IEnvironment>(environment);
using var provider = services.BuildServiceProvider();
var client = provider.GetServices<IAgentClient>().Single(client => string.Equals(client.Id, clientId, StringComparisons.AgentClientId));
var scope = scopeName == "project" ? AgentConfigurationScope.Project : AgentConfigurationScope.Global;
var path = client.GetMcpConfigPath(scope, workspace.WorkspaceRoot);
var serversPropertyName = clientId == "opencode" ? "mcp" : "mcpServers";
var original = $$"""{ "preference": true, "{{serversPropertyName}}": { "aspire": {{serverConfiguration}} } }""";
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await File.WriteAllTextAsync(path, original);
var result = provider.GetRequiredService<RootCommand>().Parse(
$"agent init --agents {clientId} --scope {scopeName} --skills none --mcp");
Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
Assert.Equal(original, await File.ReadAllTextAsync(path));
}
[Theory]
[InlineData("project")]
[InlineData("global")]
public async Task AgentInit_OpenCodeJsonc_PreservesExistingSettingsWhenAddingServer(string scopeName)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var homeDirectory = workspace.CreateDirectory("fake-home");
var environment = new TestEnvironment();
var executionContext = TestExecutionContextHelper.CreateExecutionContext(workspace.WorkspaceRoot, homeDirectory: homeDirectory);
var scope = scopeName == "project" ? AgentConfigurationScope.Project : AgentConfigurationScope.Global;
var path = scope is AgentConfigurationScope.Project ? OpenCodePaths.GetMcpConfigPath(workspace.WorkspaceRoot)
: OpenCodePaths.GetGlobalMcpConfigPath(executionContext, environment);
const string original =
"""
{
// An existing schema must not be replaced.
"$schema": "https://example.test/custom-schema.json",
"quoted": "a \" // text inside a string",
/* Block comments are valid JSONC too. */
"mcp": {
"other": { "url": "https://example.test/a//b", },
},
}
""";
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await File.WriteAllTextAsync(path, original);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliExecutionContextFactory = _ => executionContext;
});
services.AddSingleton<IEnvironment>(environment);
using var provider = services.BuildServiceProvider();
var result = provider.GetRequiredService<RootCommand>().Parse(
$"agent init --agents opencode --scope {scopeName} --skills none --mcp");
Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
await Verify(await File.ReadAllTextAsync(path), "json").UseParameters(scopeName);
}
[Theory]
[InlineData("project")]
[InlineData("global")]
public async Task AgentInit_PinnedOpenCodeJsonc_PreservesCommentsAndConfiguration(string scopeName)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var homeDirectory = workspace.CreateDirectory("fake-home");
var environment = new TestEnvironment();
var executionContext = TestExecutionContextHelper.CreateExecutionContext(workspace.WorkspaceRoot, homeDirectory: homeDirectory);
var scope = scopeName == "project" ? AgentConfigurationScope.Project : AgentConfigurationScope.Global;
var path = scope is AgentConfigurationScope.Project ? OpenCodePaths.GetMcpConfigPath(workspace.WorkspaceRoot)
: OpenCodePaths.GetGlobalMcpConfigPath(executionContext, environment);
const string original =
"""
{
// Keep the user's pinned command and formatting.
"mcp": { "aspire": { "command": ["custom-aspire", "--pinned", ], "enabled": false, }, },
}
""";
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await File.WriteAllTextAsync(path, original);
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliExecutionContextFactory = _ => executionContext;
});
services.AddSingleton<IEnvironment>(environment);
using var provider = services.BuildServiceProvider();
var before = GetFilesWithoutCliLogs(workspace.WorkspaceRoot);
var result = provider.GetRequiredService<RootCommand>().Parse(
$"agent init --agents opencode --scope {scopeName} --skills none --mcp");
Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
Assert.Equal(original, await File.ReadAllTextAsync(path));
Assert.Equal(before, GetFilesWithoutCliLogs(workspace.WorkspaceRoot));
}
[Theory]
[InlineData("project")]
[InlineData("global")]
public async Task AgentInit_Hooks_AreLimitedToSelectedDetectedEnvironmentsInGlobalScope(string scope)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var hooks = new TestTelemetryHookConfigurator();
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.AgentEnvironmentDetectorFactory = _ => new TestAgentEnvironmentDetector
{
DetectedEnvironments = AgentEnvironment.CopilotApp | AgentEnvironment.ClaudeCode | AgentEnvironment.OpenCode
};
options.TelemetryHookConfiguratorFactory = _ => hooks;
});
using var provider = services.BuildServiceProvider();
var result = provider.GetRequiredService<RootCommand>().Parse($"agent init --agents claude --scope {scope} --skills aspire");
Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
Assert.Equal(scope == "global" ? AgentEnvironment.ClaudeCode : AgentEnvironment.None, hooks.ConfiguredEnvironments);
}
[Fact]
public async Task McpInit_LegacyDelegation_UsesClientScopeAndRemoteSkillOptions()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
using var provider = CliTestHelper.CreateServiceCollection(workspace, outputHelper).BuildServiceProvider();
var result = provider.GetRequiredService<RootCommand>().Parse(
"mcp init --agents claude --scope project --skills aspire --mcp");
Assert.Empty(result.Errors);
Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
var claude = provider.GetServices<IAgentClient>().Single(client =>
string.Equals(client.Id, "claude", StringComparisons.AgentClientId));
Assert.True(claude.HasIntegrationRegistration(
workspace.WorkspaceRoot, AgentConfigurationScope.Project, [SkillSource.BuiltIn]));
Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, ".mcp.json")));
}
[Theory]
[InlineData("copied")]
[InlineData("shared")]
[InlineData("unreadable")]
[InlineData("malformed")]
public async Task ExistingSources_WarnForCopiedSkillsButStopForInvalidSettings(string existing)
{
using var context = new AgentConfigurationTestContext(outputHelper);
var hasCopiedSkills = existing is "copied" or "shared";
var path = existing == "malformed"
? Path.Combine(context.CopilotDirectory, "settings.json")
: Path.Combine(context.Home.FullName, ".agents", "skills", "aspire", "SKILL.md");
var content = hasCopiedSkills ? "My customized Aspire instructions." : "{broken";
if (existing == "unreadable")
{
Directory.CreateDirectory(path);
}
else
{
await AgentConfigurationTestContext.WriteAsync(path, content);
}
var projectDirectory = existing == "shared" ? ".claude" : ".agents";
var projectSkillPath = Path.Combine(context.Project.FullName, projectDirectory, "skills", "aspire-init", "SKILL.md");
if (hasCopiedSkills)
{
await AgentConfigurationTestContext.WriteAsync(projectSkillPath, content);
}
var interaction = new TestInteractionService();
var hooks = new TestTelemetryHookConfigurator();
var services = CliTestHelper.CreateServiceCollection(context.Workspace, outputHelper, options =>
{
options.CliExecutionContextFactory = _ => context.ExecutionContext;
options.InteractionServiceFactory = _ => interaction;
options.AgentEnvironmentDetectorFactory = _ => new TestAgentEnvironmentDetector { DetectedEnvironments = AgentEnvironment.CopilotCli };
options.NpmRunnerFactory = _ => new FakeNpmRunner { IsAvailable = false };
options.TelemetryHookConfiguratorFactory = _ => hooks;
});
services.AddSingleton<IEnvironment>(context.Environment);
using var provider = services.BuildServiceProvider();
var before = GetFilesWithoutCliLogs(context.Workspace.WorkspaceRoot);
var result = provider.GetRequiredService<RootCommand>().Parse(
"agent init --agents copilot --scope global --skills all --mcp");
Assert.Equal(hasCopiedSkills ? CliExitCodes.Success : CliExitCodes.InvalidCommand,
await result.InvokeAsync().DefaultTimeout());
if (existing == "unreadable")
{
Assert.True(Directory.Exists(path));
}
else
{
Assert.Equal(content, await File.ReadAllTextAsync(path));
}
if (hasCopiedSkills)
{
Assert.Empty(interaction.DisplayedErrors);
Assert.Contains(interaction.DisplayedMessages, message => message.Emoji.Equals(KnownEmojis.PoliceCarLight) &&
message.Message == string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.InitCommand_LocalSkillsConflict,
string.Join(System.Environment.NewLine,
$" {projectDirectory}/skills/aspire-init/SKILL.md",
" ~/.agents/skills/aspire/SKILL.md")));
Assert.Equal(content, await File.ReadAllTextAsync(projectSkillPath));
Assert.True(File.Exists(context.Copilot.GetIntegrationConfigPath(AgentConfigurationScope.Global, context.Project)));
Assert.True(File.Exists(context.Copilot.GetMcpConfigPath(AgentConfigurationScope.Global, context.Project)));
Assert.False(File.Exists(context.ClaudeCode.GetIntegrationConfigPath(AgentConfigurationScope.Global, context.Project)));
Assert.Equal(AgentEnvironment.CopilotCli, hooks.ConfiguredEnvironments);
}
else
{
Assert.Single(interaction.DisplayedErrors);
if (existing == "malformed")
{
var mcpPath = context.Copilot.GetMcpConfigPath(AgentConfigurationScope.Global, context.Project);
Assert.Equal(before.Append(mcpPath).Order(StringComparer.Ordinal),
GetFilesWithoutCliLogs(context.Workspace.WorkspaceRoot));
Assert.Equal(AgentEnvironment.CopilotCli, hooks.ConfiguredEnvironments);
}
else
{
Assert.Equal(before, GetFilesWithoutCliLogs(context.Workspace.WorkspaceRoot));
Assert.Equal(AgentEnvironment.None, hooks.ConfiguredEnvironments);
}
}
}
private static string[] GetFilesWithoutCliLogs(DirectoryInfo workspaceRoot)
{
// Agent no-op assertions still allow the CLI's normal diagnostic log writes.
var logDirectory = Path.Combine(".aspire", "logs") + Path.DirectorySeparatorChar;
return [.. Directory.EnumerateFiles(workspaceRoot.FullName, "*", SearchOption.AllDirectories)
.Where(path => !Path.GetRelativePath(workspaceRoot.FullName, path).StartsWith(logDirectory, StringComparison.Ordinal))
.Order(StringComparer.Ordinal)];
}
}