// 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.Commands;
using Aspire.Cli.Resources;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.DependencyInjection;
namespace Aspire.Cli.Tests.Commands;
public class AgentInitClientSelectionTests(ITestOutputHelper outputHelper)
{
[Theory]
[InlineData("copilot", "project", ".agents")]
[InlineData("opencode", "project", ".agents")]
[InlineData("claude", "project", ".claude")]
[InlineData("copilot", "global", ".agents")]
[InlineData("opencode", "global", ".agents")]
[InlineData("claude", "global", ".claude")]
public async Task AgentInit_ExplicitUndetectedClient_InstallsCopiedSkillsOnlyInSelectedScope(string client, string scope, string skillFolder)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var homeDirectory = workspace.CreateDirectory("fake-home");
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 {client} --scope {scope} --skills aspire --mcp=false");
Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
Assert.Equal(scope == "global" ? 1 : 0, detector.ScanCount);
var root = scope == "project" ? workspace.WorkspaceRoot : homeDirectory;
var expected = Path.Combine(root.FullName, skillFolder, "skills");
var candidates = new[]
{
Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills"),
Path.Combine(workspace.WorkspaceRoot.FullName, ".claude", "skills"),
Path.Combine(homeDirectory.FullName, ".agents", "skills"),
Path.Combine(homeDirectory.FullName, ".claude", "skills"),
Path.Combine(workspace.WorkspaceRoot.FullName, ".github", "skills"),
Path.Combine(workspace.WorkspaceRoot.FullName, ".opencode", "skills"),
Path.Combine(workspace.WorkspaceRoot.FullName, ".opencode", "skill"),
Path.Combine(homeDirectory.FullName, ".github", "skills"),
Path.Combine(homeDirectory.FullName, ".opencode", "skills"),
Path.Combine(homeDirectory.FullName, ".opencode", "skill")
};
Assert.Equal([expected], candidates.Where(Directory.Exists));
Assert.Equal(
["SKILL.md", Path.Combine("references", "app-commands.md")],
Directory.EnumerateFiles(Path.Combine(expected, "aspire"), "*", SearchOption.AllDirectories)
.Select(path => Path.GetRelativePath(Path.Combine(expected, "aspire"), path)).Order(StringComparer.Ordinal));
}
[Theory]
[InlineData("project")]
[InlineData("global")]
public async Task AgentInit_UniversalClients_LeaveExistingAgentSpecificSkillsUntouched(string scope)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var homeDirectory = workspace.CreateDirectory("fake-home");
const string original = "existing agent-specific skill";
var existingPaths = new List<string>();
foreach (var root in new[] { workspace.WorkspaceRoot, homeDirectory })
{
foreach (var folder in new[] { ".github", ".opencode" })
{
var directory = Path.Combine(root.FullName, folder, "skills", "aspire");
Directory.CreateDirectory(directory);
var path = Path.Combine(directory, "SKILL.md");
await File.WriteAllTextAsync(path, original);
existingPaths.Add(path);
}
}
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.CliExecutionContextFactory = _ => TestExecutionContextHelper.CreateExecutionContext(workspace.WorkspaceRoot, homeDirectory: homeDirectory);
});
using var provider = services.BuildServiceProvider();
var result = provider.GetRequiredService<RootCommand>().Parse(
$"agent init --agents copilot,opencode --scope {scope} --skills aspire --mcp=false");
Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
var selectedRoot = scope == "project" ? workspace.WorkspaceRoot : homeDirectory;
Assert.True(File.Exists(Path.Combine(selectedRoot.FullName, ".agents", "skills", "aspire", "SKILL.md")));
foreach (var path in existingPaths)
{
Assert.Equal(original, await File.ReadAllTextAsync(path));
Assert.Equal([path], Directory.EnumerateFiles(Path.GetDirectoryName(path)!, "*", SearchOption.AllDirectories));
}
}
[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());
Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, ".agents", "skills", "aspire", "SKILL.md")));
Assert.False(Directory.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, ".claude")));
Assert.True(AgentClient.Copilot.Includes(AgentEnvironment.CopilotApp));
Assert.True(AgentClient.Copilot.Includes(AgentEnvironment.CopilotCli));
Assert.True(AgentClient.Copilot.Includes(AgentEnvironment.VsCode));
}
[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 installSkills, 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 {(installSkills ? "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<AgentClient>().Select(client => client.Id));
var executionContext = provider.GetRequiredService<CliExecutionContext>();
var environment = provider.GetRequiredService<IEnvironment>();
var selectedScope = scope == "project" ? AgentConfigurationScope.Project : AgentConfigurationScope.Global;
var defaults = AgentClient.All.Where(client => expectedClients.Split(',').Contains(client.Id)).ToArray();
Assert.Equal(
configureMcp
? defaults.Select(client => client.GetMcpConfigPath(selectedScope, workspace.WorkspaceRoot, executionContext, environment)).Distinct().Order(StringComparer.Ordinal)
: [],
AgentClient.All.Select(client => client.GetMcpConfigPath(selectedScope, workspace.WorkspaceRoot, executionContext, environment))
.Distinct().Where(File.Exists).Order(StringComparer.Ordinal));
Assert.Equal(
installSkills
? defaults.Select(client => client.GetSkillBaseDirectory(selectedScope, workspace.WorkspaceRoot, executionContext, environment)).Distinct().Order(StringComparer.Ordinal)
: [],
AgentClient.All.Select(client => client.GetSkillBaseDirectory(selectedScope, workspace.WorkspaceRoot, executionContext, environment))
.Distinct().Where(directory => File.Exists(Path.Combine(directory, "aspire", "SKILL.md"))).Order(StringComparer.Ordinal));
var unselectedRoot = scope == "project" ? homeDirectory : workspace.WorkspaceRoot;
Assert.False(Directory.Exists(Path.Combine(unselectedRoot.FullName, ".agents")));
Assert.Equal(
(installSkills || configureMcp) && selectedScope is AgentConfigurationScope.Global && detectedEnvironment is { } detected &&
defaults.Any(client => client.Includes(detected)) ? 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));
}
[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([AgentClient.Copilot, AgentClient.ClaudeCode, AgentClient.OpenCode], choices.Cast<AgentClient>());
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);
var homeDirectory = workspace.CreateDirectory("fake-home");
var labels = new List<string>();
var locations = new List<string>();
var interaction = new TestInteractionService
{
PromptForSelectionsCallback = (prompt, choices, formatter, _) =>
{
if (prompt == AgentCommandStrings.InitCommand_SelectSkills)
{
return installSkills
? choices.Cast<object>().OfType<SkillDefinition>().Where(static skill => skill.HasName("aspire")).Cast<object>().ToArray()
: [];
}
Assert.Equal(AgentCommandStrings.InitCommand_SelectAgentsPrompt, prompt);
labels.AddRange(choices.Cast<object>().Select(formatter));
return [AgentClient.Copilot];
},
DisplaySubtleMessageCallback = message =>
{
if (message.StartsWith(AgentCommandStrings.InitCommand_InstallationLocations, StringComparison.Ordinal))
{
locations.Add(message);
}
}
};
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 ? "aspire" : "none")} --mcp={configureMcp}");
Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
await Verify(new { Agents = labels, Locations = Assert.Single(locations) }).UseParameters(scope, installSkills, configureMcp);
}
[Fact]
public async Task AgentInit_PromptsForAgentsBeforeAssetsAndScope()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var homeDirectory = workspace.CreateDirectory("fake-home");
var prompts = new List<string>();
var interaction = new TestInteractionService
{
PromptForSelectionsCallback = (prompt, choices, _, _) =>
{
prompts.Add(prompt);
return prompt == AgentCommandStrings.InitCommand_SelectSkills
? choices.Cast<object>().OfType<SkillDefinition>().Where(skill => skill.HasName("aspire")).Cast<object>().ToArray()
: [AgentClient.Copilot];
},
ConfirmCallback = (prompt, _) =>
{
prompts.Add(prompt);
return true;
},
PromptForSelectionCallback = (prompt, choices, _, _) =>
{
prompts.Add(prompt);
Assert.Equal([AgentConfigurationScope.Project, AgentConfigurationScope.Global], choices.Cast<AgentConfigurationScope>());
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;
});
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.True(File.Exists(Path.Combine(homeDirectory.FullName, ".agents", "skills", "aspire", "SKILL.md")));
Assert.True(File.Exists(Path.Combine(homeDirectory.FullName, ".copilot", "mcp-config.json")));
}
[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, _, _, _) =>
{
return prompt == AgentCommandStrings.InitCommand_SelectAgentsPrompt ? [AgentClient.Copilot] : [];
},
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 --skills none");
Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
Assert.Equal(before, GetFilesWithoutCliLogs(workspace.WorkspaceRoot));
Assert.Empty(homeDirectory.GetFileSystemInfos());
}
[Fact]
public async Task AgentInit_DuplicateClientsAndSharedDestinations_AreReportedOnce()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var interaction = new TestInteractionService
{
PromptForSelectionsCallback = (_, choices, _, _) => choices.Cast<object>()
.OfType<SkillDefinition>()
.Where(skill => skill.HasName("aspire")).Cast<object>().ToList()
};
var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options =>
{
options.InteractionServiceFactory = _ => interaction;
});
using var provider = services.BuildServiceProvider();
var result = provider.GetRequiredService<RootCommand>().Parse("agent init --agents COPILOT,copilot,OpenCode --skills aspire");
Assert.Equal(CliExitCodes.Success, await result.InvokeAsync().DefaultTimeout());
var summary = Assert.Single(interaction.DisplayedMessages, message => message.Message.StartsWith(AgentCommandStrings.InitCommand_InstalledSkillsSummary, StringComparison.Ordinal));
await Verify(summary.Message);
}
[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 = AgentClient.All.Select(client => client.GetMcpConfigPath(selectedScope, workspace.WorkspaceRoot, executionContext, environment)).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(
AgentClient.OpenCode.GetMcpConfigPath(scope, workspace.WorkspaceRoot, 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());
Assert.Equal(expectedPath, AgentClient.OpenCode.GetMcpConfigPath(scope, workspace.WorkspaceRoot, executionContext, environment));
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(AgentClient.OpenCode.GetMcpConfigPath(scope, workspace.WorkspaceRoot, 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 = AgentClient.OpenCode.GetMcpConfigPath(scope, workspace.WorkspaceRoot, 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 OpenCodeMcpConfig_LocalPartialEntry_UsesSiblingCommand()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var jsoncPath = Path.Combine(workspace.WorkspaceRoot.FullName, "opencode.jsonc");
var jsonPath = Path.ChangeExtension(jsoncPath, ".json");
await File.WriteAllTextAsync(jsonPath, """{"mcp":{"aspire":{"type":"local","command":["aspire","mcp","start"]}}}""");
await File.WriteAllTextAsync(jsoncPath, """{"mcp":{"aspire":{"type":"local","enabled":true}}}""");
var (_, configFilePath) = await McpConfigFileHelper.ReadSelectedConfigAsync(
jsoncPath, AgentClient.OpenCode, AgentConfigurationScope.Project, new TestEnvironment(), CancellationToken.None);
Assert.Equal(jsonPath, configFilePath);
}
[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,
AgentClient.OpenCode.DisplayName, 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 = AgentClient.OpenCode.GetMcpConfigPath(scope, workspace.WorkspaceRoot, 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 = AgentClient.OpenCode.GetMcpConfigPath(scope, workspace.WorkspaceRoot, 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 executionContext = TestExecutionContextHelper.CreateExecutionContext(workspace.WorkspaceRoot);
var originals = new Dictionary<string, string>();
foreach (var client in AgentClient.All)
{
var path = client.GetMcpConfigPath(AgentConfigurationScope.Global, workspace.WorkspaceRoot, executionContext, environment);
var content = client == AgentClient.OpenCode
? """{ "mcp": { "aspire": { "command": ["pinned-aspire"] } }, "preference": true }"""
: """{ "mcpServers": { "aspire": { "command": "pinned-aspire" } }, "preference": true }""";
originals.Add(path, content);
await File.WriteAllTextAsync(path, content);
}
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 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 client = AgentClient.Copilot;
var path = client.GetMcpConfigPath(AgentConfigurationScope.Global, workspace.WorkspaceRoot, executionContext, new TestEnvironment());
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 client = AgentClient.Copilot;
var path = client.GetMcpConfigPath(AgentConfigurationScope.Global, workspace.WorkspaceRoot, executionContext, new TestEnvironment());
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 client = AgentClient.All.Single(client => client.Id == clientId);
var scope = scopeName == "project" ? AgentConfigurationScope.Project : AgentConfigurationScope.Global;
var path = client.GetMcpConfigPath(scope, workspace.WorkspaceRoot, executionContext, environment);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
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 {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 client = AgentClient.All.Single(client => client.Id == clientId);
var scope = scopeName == "project" ? AgentConfigurationScope.Project : AgentConfigurationScope.Global;
var path = client.GetMcpConfigPath(scope, workspace.WorkspaceRoot, executionContext, environment);
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 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 {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 client = AgentClient.All.Single(client => client.Id == clientId);
var scope = scopeName == "project" ? AgentConfigurationScope.Project : AgentConfigurationScope.Global;
var path = client.GetMcpConfigPath(scope, workspace.WorkspaceRoot, executionContext, environment);
var original = $$"""{ "preference": true, "{{client.McpServersPropertyName}}": { "aspire": {{serverConfiguration}} } }""";
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 {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 = AgentClient.OpenCode.GetMcpConfigPath(scope, workspace.WorkspaceRoot, 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 = AgentClient.OpenCode.GetMcpConfigPath(scope, workspace.WorkspaceRoot, 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_UsesClientScopeAndCopiedSkillOptions()
{
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());
Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, ".claude", "skills", "aspire", "SKILL.md")));
Assert.True(File.Exists(Path.Combine(workspace.WorkspaceRoot.FullName, ".mcp.json")));
}
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)];
}
}