From 1b12a6cd6ebbaf448d3939c4ed07265c8522902f Mon Sep 17 00:00:00 2001 From: Zack Way Date: Thu, 12 Feb 2026 12:13:25 -0800 Subject: [PATCH 1/4] feat: add .NET 10 port of APM CLI Full port of the APM CLI from Python to .NET 10, packaged as a dnx tool for install-free execution. Includes: - Spectre.Console.Cli for CLI framework (maps to Python Click+Rich) - 89 source files across Commands, Models, Core, Compilation, Dependencies, Integration, Primitives, Runtime, Adapters, Workflow - 38 xUnit test files with 1173 tests (FakeItEasy + AwesomeAssertions) - NativeAOT support for standalone binaries (5 RIDs) - Central Package Version Management (Directory.Packages.props) - Cross-platform PowerShell 7+ install script - Source-generated JSON serialization for AOT compatibility - Full adapter wiring: VSCode, Codex, Copilot MCP server configuration - Conflict detection, safe installation, auto-install virtual packages - PackAsTool=true with ToolCommandName=apm, PackageId=apm-cli --- .gitignore | 3 + src/apm-dotnet/.gitignore | 5 + src/apm-dotnet/Directory.Build.props | 8 + src/apm-dotnet/Directory.Packages.props | 22 + src/apm-dotnet/README.md | 49 + src/apm-dotnet/apm-dotnet.slnx | 8 + src/apm-dotnet/dotnet-tools.json | 5 + src/apm-dotnet/install.ps1 | 335 ++++ .../Adapters/Client/CodexClientAdapter.cs | 451 ++++++ .../Adapters/Client/CopilotClientAdapter.cs | 540 +++++++ .../Apm.Cli/Adapters/Client/IClientAdapter.cs | 35 + .../Adapters/Client/VSCodeClientAdapter.cs | 348 +++++ .../DefaultMcpPackageManager.cs | 97 ++ .../PackageManager/IPackageManagerAdapter.cs | 19 + src/apm-dotnet/src/Apm.Cli/Apm.Cli.csproj | 47 + .../src/Apm.Cli/Commands/CompileCommand.cs | 501 ++++++ .../Commands/Config/ConfigGetCommand.cs | 54 + .../Commands/Config/ConfigSetCommand.cs | 60 + .../Commands/Config/ConfigShowCommand.cs | 53 + .../Apm.Cli/Commands/Deps/DepsListCommand.cs | 223 +++ .../Apm.Cli/Commands/Deps/DepsTreeCommand.cs | 147 ++ .../Commands/Deps/DepsUninstallCommand.cs | 237 +++ .../Commands/Deps/DepsVerifyCommand.cs | 94 ++ .../src/Apm.Cli/Commands/InitCommand.cs | 210 +++ .../src/Apm.Cli/Commands/InstallCommand.cs | 584 +++++++ .../src/Apm.Cli/Commands/ListCommand.cs | 74 + .../src/Apm.Cli/Commands/PreviewCommand.cs | 150 ++ .../src/Apm.Cli/Commands/RunCommand.cs | 99 ++ .../src/Apm.Cli/Compilation/AgentsCompiler.cs | 629 ++++++++ .../Apm.Cli/Compilation/ClaudeFormatter.cs | 471 ++++++ .../Apm.Cli/Compilation/CompilationConfig.cs | 160 ++ .../Compilation/CompilationConstants.cs | 16 + .../Apm.Cli/Compilation/CompilationResult.cs | 12 + .../src/Apm.Cli/Compilation/Constitution.cs | 26 + .../Apm.Cli/Compilation/ConstitutionBlock.cs | 90 ++ .../Apm.Cli/Compilation/ContextOptimizer.cs | 670 ++++++++ .../Compilation/DistributedCompiler.cs | 526 +++++++ .../Apm.Cli/Compilation/IClaudeFormatter.cs | 26 + .../Compilation/IConstitutionInjector.cs | 17 + .../Compilation/IDistributedCompiler.cs | 58 + .../src/Apm.Cli/Compilation/ILinkResolver.cs | 14 + .../Apm.Cli/Compilation/ITemplateBuilder.cs | 19 + .../src/Apm.Cli/Compilation/Injector.cs | 104 ++ .../src/Apm.Cli/Compilation/LinkResolver.cs | 272 ++++ .../Apm.Cli/Compilation/TemplateBuilder.cs | 105 ++ .../src/Apm.Cli/Compilation/TemplateData.cs | 9 + .../src/Apm.Cli/Core/ClientFactory.cs | 25 + .../src/Apm.Cli/Core/Configuration.cs | 88 ++ .../src/Apm.Cli/Core/ConflictDetector.cs | 277 ++++ src/apm-dotnet/src/Apm.Cli/Core/DockerArgs.cs | 106 ++ src/apm-dotnet/src/Apm.Cli/Core/Operations.cs | 125 ++ .../src/Apm.Cli/Core/PackageManagerFactory.cs | 23 + .../src/Apm.Cli/Core/SafeInstaller.cs | 140 ++ .../src/Apm.Cli/Core/ScriptRunner.cs | 690 +++++++++ .../src/Apm.Cli/Core/TargetDetection.cs | 93 ++ .../src/Apm.Cli/Core/TokenManager.cs | 235 +++ .../src/Apm.Cli/Dependencies/Aggregator.cs | 114 ++ .../src/Apm.Cli/Dependencies/ApmResolver.cs | 341 +++++ .../Apm.Cli/Dependencies/CollectionParser.cs | 133 ++ .../Apm.Cli/Dependencies/DependencyGraph.cs | 180 +++ .../Apm.Cli/Dependencies/GitHubDownloader.cs | 601 ++++++++ .../Dependencies/IPackageDownloader.cs | 12 + .../src/Apm.Cli/Dependencies/Lockfile.cs | 190 +++ .../Apm.Cli/Dependencies/PackageValidator.cs | 189 +++ .../src/Apm.Cli/Dependencies/Verifier.cs | 133 ++ .../Apm.Cli/Integration/AgentIntegrator.cs | 186 +++ .../Apm.Cli/Integration/CommandIntegrator.cs | 203 +++ .../Apm.Cli/Integration/IntegrationUtils.cs | 37 + .../Apm.Cli/Integration/PromptIntegrator.cs | 155 ++ .../Apm.Cli/Integration/SkillIntegrator.cs | 705 +++++++++ .../Apm.Cli/Integration/SkillTransformer.cs | 70 + .../src/Apm.Cli/Models/ApmPackage.cs | 223 +++ .../src/Apm.Cli/Models/DependencyReference.cs | 594 ++++++++ src/apm-dotnet/src/Apm.Cli/Models/Enums.cs | 88 ++ .../src/Apm.Cli/Models/PackageInfo.cs | 51 + .../src/Apm.Cli/Output/Formatters.cs | 424 ++++++ .../src/Apm.Cli/Output/OutputModels.cs | 127 ++ .../src/Apm.Cli/Output/ScriptFormatters.cs | 129 ++ .../Apm.Cli/Primitives/PrimitiveDiscovery.cs | 355 +++++ .../src/Apm.Cli/Primitives/PrimitiveModels.cs | 255 ++++ .../src/Apm.Cli/Primitives/PrimitiveParser.cs | 230 +++ src/apm-dotnet/src/Apm.Cli/Program.cs | 68 + .../src/Apm.Cli/Registry/RegistryClient.cs | 195 +++ .../Apm.Cli/Registry/RegistryIntegration.cs | 140 ++ .../Apm.Cli/Registry/RegistryOperations.cs | 326 ++++ .../src/Apm.Cli/Runtime/CodexRuntime.cs | 71 + .../src/Apm.Cli/Runtime/CopilotRuntime.cs | 125 ++ .../src/Apm.Cli/Runtime/IRuntimeAdapter.cs | 17 + .../src/Apm.Cli/Runtime/LlmRuntime.cs | 95 ++ .../src/Apm.Cli/Runtime/RuntimeBase.cs | 112 ++ .../src/Apm.Cli/Runtime/RuntimeFactory.cs | 102 ++ .../src/Apm.Cli/Runtime/RuntimeManager.cs | 133 ++ .../src/Apm.Cli/Utils/ApmJsonContext.cs | 17 + .../src/Apm.Cli/Utils/ConsoleHelpers.cs | 170 +++ .../src/Apm.Cli/Utils/GitHubHost.cs | 173 +++ .../Apm.Cli/Utils/JsonSerializationHelper.cs | 59 + .../src/Apm.Cli/Utils/VersionChecker.cs | 192 +++ .../src/Apm.Cli/Utils/VersionInfo.cs | 33 + .../src/Apm.Cli/Workflow/WorkflowDiscovery.cs | 120 ++ .../src/Apm.Cli/Workflow/WorkflowParser.cs | 94 ++ .../src/Apm.Cli/Workflow/WorkflowRunner.cs | 167 ++ .../Adapters/ClientFactoryTests.cs | 71 + .../Adapters/CodexClientAdapterTests.cs | 104 ++ .../Adapters/CwdTestsCollection.cs | 7 + .../Adapters/DefaultMcpPackageManagerTests.cs | 133 ++ .../Adapters/VSCodeClientAdapterTests.cs | 144 ++ .../tests/Apm.Cli.Tests/Apm.Cli.Tests.csproj | 26 + .../Commands/CommandIntegrationTests.cs | 687 +++++++++ .../Commands/SelectiveInstallTests.cs | 122 ++ .../Commands/UninstallReintegrationTests.cs | 227 +++ .../Compilation/AgentsCompilerTests.cs | 1108 ++++++++++++++ .../Compilation/ClaudeFormatterTests.cs | 1100 ++++++++++++++ .../Compilation/ContextOptimizerTests.cs | 771 ++++++++++ .../Compilation/LinkResolverTests.cs | 709 +++++++++ .../Compilation/TemplateBuilderTests.cs | 328 ++++ .../Apm.Cli.Tests/Core/ConfigurationTests.cs | 128 ++ .../Core/ConflictDetectorTests.cs | 311 ++++ .../Apm.Cli.Tests/Core/DockerArgsTests.cs | 225 +++ .../Apm.Cli.Tests/Core/OperationsTests.cs | 138 ++ .../Apm.Cli.Tests/Core/SafeInstallerTests.cs | 260 ++++ .../Apm.Cli.Tests/Core/ScriptRunnerTests.cs | 295 ++++ .../Core/ScriptRunnerTransformTests.cs | 184 +++ .../Core/ScriptRunnerVirtualPackageTests.cs | 307 ++++ .../Core/TargetDetectionTests.cs | 191 +++ .../Apm.Cli.Tests/Core/TokenManagerTests.cs | 529 +++++++ .../Dependencies/ApmResolverTests.cs | 458 ++++++ .../Dependencies/DependencyGraphTests.cs | 382 +++++ .../Dependencies/GitHubDownloaderTests.cs | 1344 +++++++++++++++++ .../Dependencies/LockfileTests.cs | 304 ++++ .../Integration/AgentIntegratorTests.cs | 498 ++++++ .../Integration/CommandIntegratorTests.cs | 390 +++++ .../Integration/IntegrationUtilsTests.cs | 112 ++ .../Integration/PromptIntegratorTests.cs | 359 +++++ .../Integration/SkillIntegratorTests.cs | 876 +++++++++++ .../Apm.Cli.Tests/Models/ApmPackageTests.cs | 381 +++++ .../Models/DependencyReferenceTests.cs | 560 +++++++ .../tests/Apm.Cli.Tests/Models/EnumsTests.cs | 122 ++ .../Apm.Cli.Tests/Models/PackageInfoTests.cs | 135 ++ .../Primitives/PrimitiveDiscoveryTests.cs | 853 +++++++++++ .../Primitives/PrimitiveModelsTests.cs | 390 +++++ .../Primitives/PrimitiveParserTests.cs | 468 ++++++ .../Runtime/CodexRuntimeTests.cs | 98 ++ .../Runtime/CopilotRuntimeTests.cs | 101 ++ .../Apm.Cli.Tests/Runtime/LlmRuntimeTests.cs | 78 + .../Runtime/RuntimeFactoryTests.cs | 94 ++ .../Runtime/RuntimeManagerTests.cs | 66 + .../Utils/ConsoleHelpersTests.cs | 38 + .../Apm.Cli.Tests/Utils/GitHubHostTests.cs | 249 +++ .../Apm.Cli.Tests/Utils/VersionInfoTests.cs | 21 + .../Apm.Cli.Tests/Workflow/WorkflowTests.cs | 247 +++ 150 files changed, 34347 insertions(+) create mode 100644 src/apm-dotnet/.gitignore create mode 100644 src/apm-dotnet/Directory.Build.props create mode 100644 src/apm-dotnet/Directory.Packages.props create mode 100644 src/apm-dotnet/README.md create mode 100644 src/apm-dotnet/apm-dotnet.slnx create mode 100644 src/apm-dotnet/dotnet-tools.json create mode 100644 src/apm-dotnet/install.ps1 create mode 100644 src/apm-dotnet/src/Apm.Cli/Adapters/Client/CodexClientAdapter.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Adapters/Client/CopilotClientAdapter.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Adapters/Client/IClientAdapter.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Adapters/Client/VSCodeClientAdapter.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Adapters/PackageManager/DefaultMcpPackageManager.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Adapters/PackageManager/IPackageManagerAdapter.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Apm.Cli.csproj create mode 100644 src/apm-dotnet/src/Apm.Cli/Commands/CompileCommand.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Commands/Config/ConfigGetCommand.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Commands/Config/ConfigSetCommand.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Commands/Config/ConfigShowCommand.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Commands/Deps/DepsListCommand.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Commands/Deps/DepsTreeCommand.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Commands/Deps/DepsUninstallCommand.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Commands/Deps/DepsVerifyCommand.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Commands/InitCommand.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Commands/InstallCommand.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Commands/ListCommand.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Commands/PreviewCommand.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Commands/RunCommand.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Compilation/AgentsCompiler.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Compilation/ClaudeFormatter.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Compilation/CompilationConfig.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Compilation/CompilationConstants.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Compilation/CompilationResult.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Compilation/Constitution.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Compilation/ConstitutionBlock.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Compilation/ContextOptimizer.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Compilation/DistributedCompiler.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Compilation/IClaudeFormatter.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Compilation/IConstitutionInjector.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Compilation/IDistributedCompiler.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Compilation/ILinkResolver.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Compilation/ITemplateBuilder.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Compilation/Injector.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Compilation/LinkResolver.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Compilation/TemplateBuilder.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Compilation/TemplateData.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Core/ClientFactory.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Core/Configuration.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Core/ConflictDetector.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Core/DockerArgs.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Core/Operations.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Core/PackageManagerFactory.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Core/SafeInstaller.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Core/ScriptRunner.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Core/TargetDetection.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Core/TokenManager.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Dependencies/Aggregator.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Dependencies/ApmResolver.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Dependencies/CollectionParser.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Dependencies/DependencyGraph.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Dependencies/GitHubDownloader.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Dependencies/IPackageDownloader.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Dependencies/Lockfile.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Dependencies/PackageValidator.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Dependencies/Verifier.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Integration/AgentIntegrator.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Integration/CommandIntegrator.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Integration/IntegrationUtils.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Integration/PromptIntegrator.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Integration/SkillIntegrator.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Integration/SkillTransformer.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Models/ApmPackage.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Models/DependencyReference.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Models/Enums.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Models/PackageInfo.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Output/Formatters.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Output/OutputModels.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Output/ScriptFormatters.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Primitives/PrimitiveDiscovery.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Primitives/PrimitiveModels.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Primitives/PrimitiveParser.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Program.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Registry/RegistryClient.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Registry/RegistryIntegration.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Registry/RegistryOperations.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Runtime/CodexRuntime.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Runtime/CopilotRuntime.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Runtime/IRuntimeAdapter.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Runtime/LlmRuntime.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Runtime/RuntimeBase.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Runtime/RuntimeFactory.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Runtime/RuntimeManager.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Utils/ApmJsonContext.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Utils/ConsoleHelpers.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Utils/GitHubHost.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Utils/JsonSerializationHelper.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Utils/VersionChecker.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Utils/VersionInfo.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Workflow/WorkflowDiscovery.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Workflow/WorkflowParser.cs create mode 100644 src/apm-dotnet/src/Apm.Cli/Workflow/WorkflowRunner.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Adapters/ClientFactoryTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Adapters/CodexClientAdapterTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Adapters/CwdTestsCollection.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Adapters/DefaultMcpPackageManagerTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Adapters/VSCodeClientAdapterTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Apm.Cli.Tests.csproj create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Commands/CommandIntegrationTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Commands/SelectiveInstallTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Commands/UninstallReintegrationTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Compilation/AgentsCompilerTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Compilation/ClaudeFormatterTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Compilation/ContextOptimizerTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Compilation/LinkResolverTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Compilation/TemplateBuilderTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Core/ConfigurationTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Core/ConflictDetectorTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Core/DockerArgsTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Core/OperationsTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Core/SafeInstallerTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Core/ScriptRunnerTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Core/ScriptRunnerTransformTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Core/ScriptRunnerVirtualPackageTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Core/TargetDetectionTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Core/TokenManagerTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Dependencies/ApmResolverTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Dependencies/DependencyGraphTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Dependencies/GitHubDownloaderTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Dependencies/LockfileTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Integration/AgentIntegratorTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Integration/CommandIntegratorTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Integration/IntegrationUtilsTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Integration/PromptIntegratorTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Integration/SkillIntegratorTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Models/ApmPackageTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Models/DependencyReferenceTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Models/EnumsTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Models/PackageInfoTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Primitives/PrimitiveDiscoveryTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Primitives/PrimitiveModelsTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Primitives/PrimitiveParserTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Runtime/CodexRuntimeTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Runtime/CopilotRuntimeTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Runtime/LlmRuntimeTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Runtime/RuntimeFactoryTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Runtime/RuntimeManagerTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Utils/ConsoleHelpersTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Utils/GitHubHostTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Utils/VersionInfoTests.cs create mode 100644 src/apm-dotnet/tests/Apm.Cli.Tests/Workflow/WorkflowTests.cs diff --git a/.gitignore b/.gitignore index 2f76574c3..30efb23d2 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,6 @@ WIP # Internal planning documents skill-plan.md skill-strategy.md + +# NodeJS +node_modules/ diff --git a/src/apm-dotnet/.gitignore b/src/apm-dotnet/.gitignore new file mode 100644 index 000000000..ff6ab997a --- /dev/null +++ b/src/apm-dotnet/.gitignore @@ -0,0 +1,5 @@ +**/TestResults/ +**/bin/ +**/obj/ +**/packages/ +**/publish/ \ No newline at end of file diff --git a/src/apm-dotnet/Directory.Build.props b/src/apm-dotnet/Directory.Build.props new file mode 100644 index 000000000..feb4466c7 --- /dev/null +++ b/src/apm-dotnet/Directory.Build.props @@ -0,0 +1,8 @@ + + + latest + enable + enable + true + + diff --git a/src/apm-dotnet/Directory.Packages.props b/src/apm-dotnet/Directory.Packages.props new file mode 100644 index 000000000..b2a3dd13e --- /dev/null +++ b/src/apm-dotnet/Directory.Packages.props @@ -0,0 +1,22 @@ + + + true + + + + + + + + + + + + + + + + + + + diff --git a/src/apm-dotnet/README.md b/src/apm-dotnet/README.md new file mode 100644 index 000000000..aed0b9ab9 --- /dev/null +++ b/src/apm-dotnet/README.md @@ -0,0 +1,49 @@ +# APM CLI — .NET Port + +A .NET 10 NativeAOT implementation of the APM CLI — the dependency manager for AI agents. + +## Install + +### .NET Global Tool + +```bash +dotnet tool install -g apm-cli +``` + +### PowerShell (cross-platform, pwsh 7+) + +```powershell +irm https://raw.githubusercontent.com/danielmeppiel/apm/main/src/apm-dotnet/install.ps1 | iex +``` + +### dnx + +```bash +dnx run apm-cli +``` + +### Build from Source + +```bash +git clone https://github.com/danielmeppiel/apm.git +cd apm/src/apm-dotnet +dotnet build +dotnet test +dotnet run --project src/Apm.Cli +``` + +## Build & Pack + +```bash +dotnet build # Debug build +dotnet test # Run tests +dotnet pack # Create NuGet package +``` + +### NativeAOT Publish + +```bash +dotnet publish src/Apm.Cli -c Release -r linux-x64 /p:NativeAot=true +``` + +Supported RIDs: `win-x64`, `linux-x64`, `linux-arm64`, `osx-x64`, `osx-arm64`. diff --git a/src/apm-dotnet/apm-dotnet.slnx b/src/apm-dotnet/apm-dotnet.slnx new file mode 100644 index 000000000..f948f5c15 --- /dev/null +++ b/src/apm-dotnet/apm-dotnet.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/apm-dotnet/dotnet-tools.json b/src/apm-dotnet/dotnet-tools.json new file mode 100644 index 000000000..b0e38abda --- /dev/null +++ b/src/apm-dotnet/dotnet-tools.json @@ -0,0 +1,5 @@ +{ + "version": 1, + "isRoot": true, + "tools": {} +} \ No newline at end of file diff --git a/src/apm-dotnet/install.ps1 b/src/apm-dotnet/install.ps1 new file mode 100644 index 000000000..95e27ea56 --- /dev/null +++ b/src/apm-dotnet/install.ps1 @@ -0,0 +1,335 @@ +#!/usr/bin/env pwsh +# APM CLI Installer Script (.NET NativeAOT) +# Usage: irm https://raw.githubusercontent.com/danielmeppiel/apm/main/src/apm-dotnet/install.ps1 | iex +# For private repos: $env:GITHUB_APM_PAT = "your_token"; irm ... | iex + +#Requires -Version 7.0 +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# --- PowerShell version gate --- +if ($PSVersionTable.PSVersion.Major -lt 7) { + Write-Host "Error: PowerShell 7+ is required. You are running $($PSVersionTable.PSVersion)." -ForegroundColor Red + Write-Host "Install pwsh: https://aka.ms/install-powershell" -ForegroundColor Yellow + exit 1 +} + +# --- Configuration --- +$Repo = 'danielmeppiel/apm' +$BinaryName = 'apm' +$ApiUrl = "https://api.github.com/repos/$Repo/releases/latest" + +# --- Helpers --- +function Write-Banner { + Write-Host '' + Write-Host '╔══════════════════════════════════════════════════════════════╗' -ForegroundColor Cyan + Write-Host '║ APM CLI Installer ║' -ForegroundColor Cyan + Write-Host '║ The NPM for AI-Native Development ║' -ForegroundColor Cyan + Write-Host '╚══════════════════════════════════════════════════════════════╝' -ForegroundColor Cyan + Write-Host '' +} + +function Write-Step { param([string]$Msg) Write-Host $Msg -ForegroundColor Yellow } +function Write-Ok { param([string]$Msg) Write-Host "✓ $Msg" -ForegroundColor Green } +function Write-Info { param([string]$Msg) Write-Host $Msg -ForegroundColor Cyan } +function Write-Warn { param([string]$Msg) Write-Host "⚠ $Msg" -ForegroundColor Yellow } +function Write-Err { param([string]$Msg) Write-Host "❌ $Msg" -ForegroundColor Red } + +function Write-QuickStart { + Write-Host '' + Write-Host '🎉 Installation complete!' -ForegroundColor Green + Write-Host '' + Write-Info 'Quick start:' + Write-Host ' apm init my-app # Create a new APM project' + Write-Host ' cd my-app && apm install # Install dependencies' + Write-Host ' apm run # Run your first prompt' + Write-Host '' + Write-Info "Documentation: https://github.com/$Repo" + Write-Info "Need help? https://github.com/$Repo/issues" +} + +# --- Platform detection --- +function Get-PlatformRid { + $os = [System.Runtime.InteropServices.RuntimeInformation]::OSDescription + $arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture + + # Determine OS component + if ($IsWindows) { + $osPart = 'win' + } elseif ($IsMacOS) { + $osPart = 'osx' + } elseif ($IsLinux) { + $osPart = 'linux' + } else { + Write-Err "Unsupported OS: $os" + exit 1 + } + + # Determine architecture component + switch ($arch) { + 'X64' { $archPart = 'x64' } + 'Arm64' { $archPart = 'arm64' } + default { + Write-Err "Unsupported architecture: $arch" + Write-Host 'Supported: x64, arm64' -ForegroundColor Yellow + exit 1 + } + } + + return "$osPart-$archPart" +} + +# --- GitHub API helpers --- +function Get-AuthHeaders { + $headers = @{ 'User-Agent' = 'apm-installer' } + $token = if ($env:GITHUB_APM_PAT) { $env:GITHUB_APM_PAT } + elseif ($env:GITHUB_TOKEN) { $env:GITHUB_TOKEN } + else { $null } + if ($token) { + $headers['Authorization'] = "token $token" + $tokenSource = if ($env:GITHUB_APM_PAT) { 'GITHUB_APM_PAT' } else { 'GITHUB_TOKEN' } + Write-Info "Using $tokenSource for authentication" + } + return @{ Headers = $headers; HasToken = [bool]$token } +} + +function Get-LatestRelease { + $auth = Get-AuthHeaders + + # Try without auth first (public repos) + $headersNoAuth = @{ 'User-Agent' = 'apm-installer' } + try { + $response = Invoke-RestMethod -Uri $ApiUrl -Headers $headersNoAuth -ErrorAction Stop + return $response + } catch { + # Fall through to authenticated attempt + } + + # Try with auth if available + if ($auth.HasToken) { + Write-Info 'Repository appears private, retrying with authentication...' + try { + $response = Invoke-RestMethod -Uri $ApiUrl -Headers $auth.Headers -ErrorAction Stop + return $response + } catch { + Write-Err "GitHub API request failed: $_" + exit 1 + } + } + + Write-Err 'Failed to fetch release info. For private repos set GITHUB_APM_PAT or GITHUB_TOKEN.' + Write-Host ' $env:GITHUB_APM_PAT = "ghp_..."' -ForegroundColor Yellow + Write-Host ' irm https://raw.githubusercontent.com/danielmeppiel/apm/main/src/apm-dotnet/install.ps1 | iex' -ForegroundColor Yellow + exit 1 +} + +# --- Download helpers --- +function Get-AssetDownloadUrl { + param( + [object]$Release, + [string]$AssetName + ) + $asset = $Release.assets | Where-Object { $_.name -eq $AssetName } | Select-Object -First 1 + if (-not $asset) { + return $null + } + return @{ + BrowserUrl = $asset.browser_download_url + ApiUrl = $asset.url + } +} + +function Invoke-AssetDownload { + param( + [hashtable]$Urls, + [string]$OutFile + ) + $auth = Get-AuthHeaders + + # Try public browser URL first + try { + Invoke-WebRequest -Uri $Urls.BrowserUrl -OutFile $OutFile -ErrorAction Stop + return + } catch { + # Fall through + } + + # Try API URL with auth (private repos) + if ($auth.HasToken -and $Urls.ApiUrl) { + $dlHeaders = $auth.Headers.Clone() + $dlHeaders['Accept'] = 'application/octet-stream' + try { + Invoke-WebRequest -Uri $Urls.ApiUrl -Headers $dlHeaders -OutFile $OutFile -ErrorAction Stop + return + } catch { + # Fall through + } + } + + throw "Download failed for asset." +} + +# --- Installation --- +function Install-Unix { + param([string]$TarPath, [string]$Rid) + + $installDir = '/usr/local/lib/apm' + $binDir = '/usr/local/bin' + $tmpExtract = Join-Path ([System.IO.Path]::GetTempPath()) "apm-extract-$(Get-Random)" + + try { + New-Item -ItemType Directory -Path $tmpExtract -Force | Out-Null + tar -xzf $TarPath -C $tmpExtract + if ($LASTEXITCODE -ne 0) { throw "tar extraction failed" } + + # Find the extracted binary (could be in a subdirectory or root) + $binary = Get-ChildItem -Path $tmpExtract -Recurse -Filter $BinaryName | + Where-Object { -not $_.PSIsContainer } | + Select-Object -First 1 + if (-not $binary) { throw "Binary '$BinaryName' not found in archive" } + + # Determine if we need sudo + $parentDir = Split-Path $installDir -Parent + $needSudo = $true + if (Test-Path $parentDir -ErrorAction SilentlyContinue) { + & test -w $parentDir 2>&1 | Out-Null + if ($LASTEXITCODE -eq 0) { $needSudo = $false } + } + + $sudo = if ($needSudo) { 'sudo' } else { '' } + + # Install files + & bash -c "$sudo rm -rf '$installDir' && $sudo mkdir -p '$installDir' && $sudo cp -r '$($binary.Directory.FullName)'/* '$installDir/' && $sudo chmod +x '$installDir/$BinaryName' && $sudo ln -sf '$installDir/$BinaryName' '$binDir/$BinaryName'" + if ($LASTEXITCODE -ne 0) { throw "Installation to $installDir failed" } + + Write-Ok "Installed to $installDir" + Write-Info "Symlink: $binDir/$BinaryName -> $installDir/$BinaryName" + } finally { + Remove-Item -Path $tmpExtract -Recurse -Force -ErrorAction SilentlyContinue + } +} + +function Install-Windows { + param([string]$ZipPath) + + $installDir = Join-Path $env:LOCALAPPDATA 'apm' + + # Clean previous installation + if (Test-Path $installDir) { + Remove-Item -Path $installDir -Recurse -Force + } + + # Extract + Expand-Archive -Path $ZipPath -DestinationPath $installDir -Force + + # If archive contains a nested folder, flatten it + $nested = Get-ChildItem -Path $installDir -Directory | Select-Object -First 1 + if ($nested -and (Test-Path (Join-Path $nested.FullName "$BinaryName.exe"))) { + $nestedPath = $nested.FullName + Get-ChildItem -Path $nestedPath | Move-Item -Destination $installDir -Force + Remove-Item -Path $nestedPath -Recurse -Force + } + + Write-Ok "Installed to $installDir" + + # Add to user PATH if not already present + $userPath = [System.Environment]::GetEnvironmentVariable('PATH', 'User') + if ($userPath -notlike "*$installDir*") { + [System.Environment]::SetEnvironmentVariable('PATH', "$installDir;$userPath", 'User') + $env:PATH = "$installDir;$env:PATH" + Write-Ok 'Added to user PATH (restart your terminal for changes to take effect)' + } +} + +# ============================================================ +# Main +# ============================================================ +try { + Write-Banner + + # 1 - Detect platform + $rid = Get-PlatformRid + Write-Info "Detected platform: $rid" + + # Determine asset name: .zip for Windows, .tar.gz otherwise + if ($rid.StartsWith('win')) { + $assetName = "apm-$rid.zip" + } else { + $assetName = "apm-$rid.tar.gz" + } + Write-Info "Target asset: $assetName" + + # 2 - Fetch latest release + Write-Step 'Fetching latest release...' + $release = Get-LatestRelease + $tagName = $release.tag_name + if (-not $tagName) { + Write-Err 'Could not determine latest release version.' + exit 1 + } + Write-Ok "Latest version: $tagName" + + # 3 - Resolve download URL + $urls = Get-AssetDownloadUrl -Release $release -AssetName $assetName + if (-not $urls) { + Write-Err "No asset '$assetName' found in release $tagName." + Write-Host "Available assets:" -ForegroundColor Yellow + $release.assets | ForEach-Object { Write-Host " $($_.name)" } + Write-Host '' + Write-Warn 'Fallback: install the .NET tool instead:' + Write-Host ' dotnet tool install -g apm-cli' -ForegroundColor Cyan + exit 1 + } + + # 4 - Download + Write-Step "Downloading $assetName..." + $tmpDir = Join-Path ([System.IO.Path]::GetTempPath()) "apm-install-$(Get-Random)" + New-Item -ItemType Directory -Path $tmpDir -Force | Out-Null + $outFile = Join-Path $tmpDir $assetName + + try { + Invoke-AssetDownload -Urls $urls -OutFile $outFile + Write-Ok 'Download successful' + } catch { + Write-Err "Download failed: $_" + Write-Host '' + Write-Warn 'Fallback: install via .NET tool instead:' + Write-Host ' dotnet tool install -g apm-cli' -ForegroundColor Cyan + exit 1 + } + + # 5 - Install + Write-Step 'Installing...' + if ($rid.StartsWith('win')) { + Install-Windows -ZipPath $outFile + } else { + Install-Unix -TarPath $outFile -Rid $rid + } + + # 6 - Verify + Write-Step 'Verifying installation...' + $apmCmd = Get-Command $BinaryName -ErrorAction SilentlyContinue + if ($apmCmd) { + $version = & $BinaryName --version 2>&1 + Write-Ok "apm $version" + Write-Info "Location: $($apmCmd.Source)" + } else { + Write-Warn 'apm not found in PATH. You may need to restart your terminal.' + } + + # 7 - Quick start + Write-QuickStart + +} catch { + Write-Err "Installation failed: $_" + Write-Host '' + Write-Warn 'Alternative install methods:' + Write-Host ' dotnet tool install -g apm-cli # .NET global tool' -ForegroundColor Cyan + Write-Host " git clone https://github.com/$Repo && cd apm/src/apm-dotnet && dotnet build" -ForegroundColor Cyan + exit 1 +} finally { + # Cleanup temp directory + if ($tmpDir -and (Test-Path $tmpDir -ErrorAction SilentlyContinue)) { + Remove-Item -Path $tmpDir -Recurse -Force -ErrorAction SilentlyContinue + } +} diff --git a/src/apm-dotnet/src/Apm.Cli/Adapters/Client/CodexClientAdapter.cs b/src/apm-dotnet/src/Apm.Cli/Adapters/Client/CodexClientAdapter.cs new file mode 100644 index 000000000..280a9c7e2 --- /dev/null +++ b/src/apm-dotnet/src/Apm.Cli/Adapters/Client/CodexClientAdapter.cs @@ -0,0 +1,451 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using Apm.Cli.Registry; +using Apm.Cli.Utils; +using Tomlyn; +using Tomlyn.Model; + +namespace Apm.Cli.Adapters.Client; + +/// +/// OpenAI Codex CLI implementation of MCP client adapter. +/// Manages MCP server configuration via ~/.codex/config.toml. +/// +public class CodexClientAdapter : IClientAdapter +{ + private readonly RegistryClient _registryClient; + + public CodexClientAdapter(string? registryUrl = null) + { + _registryClient = new RegistryClient(registryUrl); + } + + public string GetConfigPath() + { + var codexDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codex"); + return Path.Combine(codexDir, "config.toml"); + } + + public bool UpdateConfig(Dictionary configUpdates) + { + var current = GetCurrentConfig(); + + if (!current.ContainsKey("mcp_servers")) + current["mcp_servers"] = new Dictionary(); + + if (current["mcp_servers"] is Dictionary servers) + { + foreach (var kvp in configUpdates) + servers[kvp.Key] = kvp.Value; + } + + var configPath = GetConfigPath(); + var dir = Path.GetDirectoryName(configPath); + if (dir is not null && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + try + { + var tomlModel = DictionaryToTomlTable(current); + var tomlText = Toml.FromModel(tomlModel); + File.WriteAllText(configPath, tomlText); + return true; + } + catch (Exception ex) + { + Console.WriteLine($"Error updating Codex configuration: {ex.Message}"); + return false; + } + } + + public Dictionary GetCurrentConfig() + { + var configPath = GetConfigPath(); + if (!File.Exists(configPath)) + return new Dictionary(); + + try + { + var text = File.ReadAllText(configPath); + var model = Toml.ToModel(text); + return TomlTableToDictionary(model); + } + catch + { + return new Dictionary(); + } + } + + public bool ConfigureMcpServer( + string serverUrl, + string? serverName = null, + bool enabled = true, + Dictionary? envOverrides = null, + Dictionary>? serverInfoCache = null, + Dictionary? runtimeVars = null) + { + if (string.IsNullOrWhiteSpace(serverUrl)) + { + Console.WriteLine("Error: serverUrl cannot be empty"); + return false; + } + + try + { + Dictionary? serverInfo = null; + if (serverInfoCache != null && serverInfoCache.TryGetValue(serverUrl, out var cached)) + serverInfo = ConvertCacheEntry(cached); + else + serverInfo = _registryClient.FindServerByReference(serverUrl); + + if (serverInfo == null || serverInfo.Count == 0) + { + Console.WriteLine($"Error: MCP server '{serverUrl}' not found in registry"); + return false; + } + + // Reject remote-only servers + var hasRemotes = serverInfo.TryGetValue("remotes", out var remotesEl) + && remotesEl.ValueKind == JsonValueKind.Array + && remotesEl.GetArrayLength() > 0; + var hasPackages = serverInfo.TryGetValue("packages", out var pkgsEl) + && pkgsEl.ValueKind == JsonValueKind.Array + && pkgsEl.GetArrayLength() > 0; + + if (hasRemotes && !hasPackages) + { + Console.WriteLine($"⚠️ Warning: MCP server '{serverUrl}' is a remote server (SSE type)"); + Console.WriteLine(" Codex CLI only supports local servers with command/args configuration"); + Console.WriteLine(" Remote servers are not supported by Codex CLI"); + Console.WriteLine(" Skipping installation for Codex CLI"); + return false; + } + + // Determine config key + string configKey; + if (serverName != null) + configKey = serverName; + else if (serverUrl.Contains('/')) + configKey = serverUrl.Split('/').Last(); + else + configKey = serverUrl; + + var serverConfig = FormatServerConfig(serverInfo, envOverrides, runtimeVars); + UpdateConfig(new Dictionary { [configKey] = serverConfig }); + + Console.WriteLine($"Successfully configured MCP server '{configKey}' for Codex CLI"); + return true; + } + catch (Exception ex) + { + Console.WriteLine($"Error configuring MCP server: {ex.Message}"); + return false; + } + } + + private Dictionary FormatServerConfig( + Dictionary serverInfo, + Dictionary? envOverrides, + Dictionary? runtimeVars) + { + runtimeVars ??= new Dictionary(); + + var config = new Dictionary + { + ["command"] = "unknown", + ["args"] = new List(), + ["env"] = new Dictionary(), + ["id"] = serverInfo.TryGetValue("id", out var idEl) && idEl.ValueKind == JsonValueKind.String + ? idEl.GetString() ?? "" : "" + }; + + if (!serverInfo.TryGetValue("packages", out var pkgsEl) || pkgsEl.ValueKind != JsonValueKind.Array) + { + var srvName = serverInfo.TryGetValue("name", out var n) && n.ValueKind == JsonValueKind.String + ? n.GetString() ?? "unknown" : "unknown"; + throw new InvalidOperationException( + $"MCP server has no package information available in registry. Server: {srvName}"); + } + + var packages = pkgsEl.EnumerateArray().ToList(); + if (packages.Count == 0) return config; + + var package = SelectBestPackage(packages); + if (package.ValueKind != JsonValueKind.Object) return config; + + var registryName = GetStr(package, "registry_name"); + var packageName = GetStr(package, "name"); + var runtimeHint = GetStr(package, "runtime_hint"); + + // Resolve environment variables + var envVars = package.TryGetProperty("environment_variables", out var evEl) && evEl.ValueKind == JsonValueKind.Array + ? evEl : (JsonElement?)null; + var resolvedEnv = envVars.HasValue + ? ProcessEnvironmentVariables(envVars.Value, envOverrides) + : new Dictionary(); + + // Process arguments + var runtimeArgs = package.TryGetProperty("runtime_arguments", out var raEl) && raEl.ValueKind == JsonValueKind.Array + ? ProcessArguments(raEl, resolvedEnv, runtimeVars) : new List(); + var packageArgs = package.TryGetProperty("package_arguments", out var paEl) && paEl.ValueKind == JsonValueKind.Array + ? ProcessArguments(paEl, resolvedEnv, runtimeVars) : new List(); + + var allArgs = runtimeArgs.Concat(packageArgs).ToList(); + + switch (registryName) + { + case "npm": + config["command"] = string.IsNullOrEmpty(runtimeHint) ? "npx" : runtimeHint; + config["args"] = allArgs.Cast().ToList(); + break; + case "docker": + config["command"] = "docker"; + config["args"] = EnsureDockerEnvFlags(allArgs, resolvedEnv).Cast().ToList(); + break; + case "pypi": + config["command"] = string.IsNullOrEmpty(runtimeHint) ? "uvx" : runtimeHint; + config["args"] = new List { packageName }.Concat(allArgs.Cast()).ToList(); + break; + case "homebrew": + config["command"] = packageName.Contains('/') ? packageName.Split('/').Last() : packageName; + config["args"] = allArgs.Cast().ToList(); + break; + default: + config["command"] = string.IsNullOrEmpty(runtimeHint) ? packageName : runtimeHint; + config["args"] = allArgs.Cast().ToList(); + break; + } + + if (resolvedEnv.Count > 0) + { + var envDict = new Dictionary(); + foreach (var kvp in resolvedEnv) envDict[kvp.Key] = kvp.Value; + config["env"] = envDict; + } + + return config; + } + + private static Dictionary ProcessEnvironmentVariables( + JsonElement envVarsArray, Dictionary? envOverrides) + { + var resolved = new Dictionary(); + envOverrides ??= new Dictionary(); + var skipPrompting = envOverrides.Count > 0 + || Environment.GetEnvironmentVariable("APM_E2E_TESTS") == "1"; + + var defaultGitHubEnv = new Dictionary + { + ["GITHUB_TOOLSETS"] = "context", + ["GITHUB_DYNAMIC_TOOLSETS"] = "1" + }; + + var emptyValueVars = new HashSet(); + foreach (var kvp in envOverrides) + { + if (string.IsNullOrWhiteSpace(kvp.Value)) + emptyValueVars.Add(kvp.Key); + } + + foreach (var envVar in envVarsArray.EnumerateArray()) + { + if (envVar.ValueKind != JsonValueKind.Object) continue; + var name = GetStr(envVar, "name"); + if (string.IsNullOrEmpty(name)) continue; + + var required = !envVar.TryGetProperty("required", out var reqEl) + || reqEl.ValueKind != JsonValueKind.False; + + string? value = envOverrides.TryGetValue(name, out var ov) && !string.IsNullOrWhiteSpace(ov) ? ov : null; + value ??= Environment.GetEnvironmentVariable(name); + + if (!string.IsNullOrWhiteSpace(value)) + resolved[name] = value; + else if (emptyValueVars.Contains(name) && defaultGitHubEnv.ContainsKey(name)) + resolved[name] = defaultGitHubEnv[name]; + else if (!required && defaultGitHubEnv.ContainsKey(name)) + resolved[name] = defaultGitHubEnv[name]; + else if (skipPrompting && defaultGitHubEnv.ContainsKey(name)) + resolved[name] = defaultGitHubEnv[name]; + } + + return resolved; + } + + private static List ProcessArguments( + JsonElement argsArray, + Dictionary resolvedEnv, + Dictionary runtimeVars) + { + var processed = new List(); + foreach (var arg in argsArray.EnumerateArray()) + { + if (arg.ValueKind == JsonValueKind.Object) + { + var argType = GetStr(arg, "type"); + if (argType == "positional") + { + var value = GetStr(arg, "value"); + if (string.IsNullOrEmpty(value)) value = GetStr(arg, "default"); + if (!string.IsNullOrEmpty(value)) + processed.Add(ResolveVariablePlaceholders(value, resolvedEnv, runtimeVars)); + } + else if (argType == "named") + { + var flagName = GetStr(arg, "value"); + if (!string.IsNullOrEmpty(flagName)) + { + processed.Add(flagName); + var additional = GetStr(arg, "name"); + if (!string.IsNullOrEmpty(additional) && additional != flagName && !additional.StartsWith("-")) + processed.Add(ResolveVariablePlaceholders(additional, resolvedEnv, runtimeVars)); + } + } + } + else if (arg.ValueKind == JsonValueKind.String) + { + var val = arg.GetString() ?? ""; + processed.Add(ResolveVariablePlaceholders(val, resolvedEnv, runtimeVars)); + } + } + return processed; + } + + private static string ResolveVariablePlaceholders( + string value, Dictionary resolvedEnv, Dictionary runtimeVars) + { + if (string.IsNullOrEmpty(value)) return value; + + // Replace with actual env values + var processed = Regex.Replace(value, @"<([A-Z_][A-Z0-9_]*)>", m => + resolvedEnv.TryGetValue(m.Groups[1].Value, out var v) ? v : m.Value); + + // Replace {runtime_var} with actual runtime values + processed = Regex.Replace(processed, @"\{([a-zA-Z_][a-zA-Z0-9_]*)\}", m => + runtimeVars.TryGetValue(m.Groups[1].Value, out var v) ? v : m.Value); + + return processed; + } + + private static List EnsureDockerEnvFlags(List baseArgs, Dictionary envVars) + { + if (envVars.Count == 0) return baseArgs; + + var result = new List(); + var existingEnvVars = new HashSet(); + + int i = 0; + while (i < baseArgs.Count) + { + result.Add(baseArgs[i]); + if (baseArgs[i] == "-e" && i + 1 < baseArgs.Count) + { + existingEnvVars.Add(baseArgs[i + 1]); + result.Add(baseArgs[i + 1]); + i += 2; + } + else + { + i++; + } + } + + // Insert missing -e flags before the image name (last non-flag arg) + var imageName = result.Count > 0 && !result[^1].StartsWith("-") ? result[^1] : null; + if (imageName != null) + { + result.RemoveAt(result.Count - 1); + foreach (var envName in envVars.Keys.OrderBy(k => k)) + { + if (!existingEnvVars.Contains(envName)) + result.AddRange(new[] { "-e", envName }); + } + result.Add(imageName); + } + else + { + foreach (var envName in envVars.Keys.OrderBy(k => k)) + { + if (!existingEnvVars.Contains(envName)) + result.AddRange(new[] { "-e", envName }); + } + } + + return result; + } + + private static JsonElement SelectBestPackage(List packages) + { + var priority = new[] { "npm", "docker", "pypi", "homebrew" }; + foreach (var reg in priority) + { + foreach (var pkg in packages) + { + if (GetStr(pkg, "registry_name") == reg) + return pkg; + } + } + return packages[0]; + } + + private static string GetStr(JsonElement el, string prop, string def = "") + { + if (el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.String) + return v.GetString() ?? def; + return def; + } + + private static Dictionary ConvertCacheEntry(Dictionary entry) + { + var result = new Dictionary(); + foreach (var (key, value) in entry) + { + if (value is JsonElement je) + result[key] = je; + else + result[key] = JsonSerializationHelper.ToJsonElement(JsonSerializationHelper.ToJsonNode(value)); + } + return result; + } + + private static Dictionary TomlTableToDictionary(TomlTable table) + { + var dict = new Dictionary(); + foreach (var kvp in table) + { + if (kvp.Value is TomlTable nested) + dict[kvp.Key] = TomlTableToDictionary(nested); + else + dict[kvp.Key] = kvp.Value; + } + return dict; + } + + private static TomlTable DictionaryToTomlTable(Dictionary dict) + { + var table = new TomlTable(); + foreach (var kvp in dict) + { + if (kvp.Value is Dictionary nested) + table[kvp.Key] = DictionaryToTomlTable(nested); + else if (kvp.Value is List list) + table[kvp.Key] = ListToTomlArray(list); + else if (kvp.Value is not null) + table[kvp.Key] = kvp.Value; + } + return table; + } + + private static TomlArray ListToTomlArray(List list) + { + var arr = new TomlArray(); + foreach (var item in list) + { + if (item is not null) + arr.Add(item); + } + return arr; + } +} diff --git a/src/apm-dotnet/src/Apm.Cli/Adapters/Client/CopilotClientAdapter.cs b/src/apm-dotnet/src/Apm.Cli/Adapters/Client/CopilotClientAdapter.cs new file mode 100644 index 000000000..4ebbc1ce6 --- /dev/null +++ b/src/apm-dotnet/src/Apm.Cli/Adapters/Client/CopilotClientAdapter.cs @@ -0,0 +1,540 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using Apm.Cli.Registry; +using Apm.Cli.Utils; + +namespace Apm.Cli.Adapters.Client; + +/// +/// GitHub Copilot CLI implementation of MCP client adapter. +/// Manages MCP server configuration via ~/.copilot/mcp-config.json. +/// +public class CopilotClientAdapter : IClientAdapter +{ + private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true }; + private readonly RegistryClient _registryClient; + + public CopilotClientAdapter(string? registryUrl = null) + { + _registryClient = new RegistryClient(registryUrl); + } + + public string GetConfigPath() + { + var copilotDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".copilot"); + return Path.Combine(copilotDir, "mcp-config.json"); + } + + public bool UpdateConfig(Dictionary configUpdates) + { + var current = GetCurrentConfig(); + + if (!current.ContainsKey("mcpServers")) + current["mcpServers"] = new Dictionary(); + + if (current["mcpServers"] is Dictionary servers) + { + foreach (var kvp in configUpdates) + servers[kvp.Key] = kvp.Value; + } + + var configPath = GetConfigPath(); + var dir = Path.GetDirectoryName(configPath); + if (dir is not null && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + try + { + var json = JsonSerializationHelper.DictToJsonObject(current).ToJsonString(WriteOptions); + File.WriteAllText(configPath, json); + return true; + } + catch (Exception ex) + { + Console.WriteLine($"Error updating Copilot configuration: {ex.Message}"); + return false; + } + } + + public Dictionary GetCurrentConfig() + { + var configPath = GetConfigPath(); + if (!File.Exists(configPath)) + return new Dictionary(); + + try + { + var text = File.ReadAllText(configPath); + var node = JsonNode.Parse(text)?.AsObject(); + if (node is null) return new Dictionary(); + + var result = new Dictionary(); + foreach (var kvp in node) + result[kvp.Key] = kvp.Value?.DeepClone(); + return result; + } + catch + { + return new Dictionary(); + } + } + + public bool ConfigureMcpServer( + string serverUrl, + string? serverName = null, + bool enabled = true, + Dictionary? envOverrides = null, + Dictionary>? serverInfoCache = null, + Dictionary? runtimeVars = null) + { + if (string.IsNullOrWhiteSpace(serverUrl)) + { + Console.WriteLine("Error: serverUrl cannot be empty"); + return false; + } + + try + { + Dictionary? serverInfo = null; + if (serverInfoCache != null && serverInfoCache.TryGetValue(serverUrl, out var cached)) + serverInfo = ConvertCacheEntry(cached); + else + serverInfo = _registryClient.FindServerByReference(serverUrl); + + if (serverInfo == null || serverInfo.Count == 0) + { + Console.WriteLine($"Error: MCP server '{serverUrl}' not found in registry"); + return false; + } + + // Determine config key + string configKey; + if (serverName != null) + configKey = serverName; + else if (serverUrl.Contains('/')) + configKey = serverUrl.Split('/').Last(); + else + configKey = serverUrl; + + var serverConfig = FormatServerConfig(serverInfo, envOverrides, runtimeVars); + + // Write directly with JsonNode for reliable nested manipulation + var configPath = GetConfigPath(); + var dir = Path.GetDirectoryName(configPath); + if (dir is not null && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + JsonObject root; + try + { + if (File.Exists(configPath)) + { + var text = File.ReadAllText(configPath); + root = JsonNode.Parse(text)?.AsObject() ?? new JsonObject(); + } + else + root = new JsonObject(); + } + catch { root = new JsonObject(); } + + if (root["mcpServers"] is not JsonObject mcpServers) + { + mcpServers = new JsonObject(); + root["mcpServers"] = mcpServers; + } + + mcpServers[configKey] = JsonSerializationHelper.DictToJsonObject(serverConfig); + File.WriteAllText(configPath, root.ToJsonString(WriteOptions)); + + Console.WriteLine($"Successfully configured MCP server '{configKey}' for Copilot CLI"); + return true; + } + catch (Exception ex) + { + Console.WriteLine($"Error configuring MCP server: {ex.Message}"); + return false; + } + } + + private Dictionary FormatServerConfig( + Dictionary serverInfo, + Dictionary? envOverrides, + Dictionary? runtimeVars) + { + runtimeVars ??= new Dictionary(); + + var config = new Dictionary + { + ["type"] = "local", + ["tools"] = new List { "*" }, + ["id"] = serverInfo.TryGetValue("id", out var idEl) && idEl.ValueKind == JsonValueKind.String + ? idEl.GetString() ?? "" : "" + }; + + // Check for remote endpoints first + if (serverInfo.TryGetValue("remotes", out var remotesEl) + && remotesEl.ValueKind == JsonValueKind.Array + && remotesEl.GetArrayLength() > 0) + { + var remote = remotesEl.EnumerateArray().First(); + config = new Dictionary + { + ["type"] = "http", + ["url"] = GetStr(remote, "url"), + ["tools"] = new List { "*" }, + ["id"] = serverInfo.TryGetValue("id", out var rid) && rid.ValueKind == JsonValueKind.String + ? rid.GetString() ?? "" : "" + }; + + // GitHub server auth + var srvName = serverInfo.TryGetValue("name", out var nEl) && nEl.ValueKind == JsonValueKind.String + ? nEl.GetString() ?? "" : ""; + if (IsGitHubServer(srvName, GetStr(remote, "url"))) + { + var token = Environment.GetEnvironmentVariable("GITHUB_PERSONAL_ACCESS_TOKEN"); + if (!string.IsNullOrEmpty(token)) + { + config["headers"] = new Dictionary + { + ["Authorization"] = $"Bearer {token}" + }; + } + } + + // Additional headers from registry + if (remote.TryGetProperty("headers", out var headersEl) && headersEl.ValueKind == JsonValueKind.Array) + { + if (config["headers"] is not Dictionary headers) + { + headers = new Dictionary(); + config["headers"] = headers; + } + foreach (var header in headersEl.EnumerateArray()) + { + var hName = GetStr(header, "name"); + var hValue = GetStr(header, "value"); + if (!string.IsNullOrEmpty(hName) && !string.IsNullOrEmpty(hValue)) + headers[hName] = ResolveEnvVariable(hName, hValue, envOverrides); + } + } + + return config; + } + + // Check packages + if (!serverInfo.TryGetValue("packages", out var pkgsEl) || pkgsEl.ValueKind != JsonValueKind.Array) + { + var srvName = serverInfo.TryGetValue("name", out var n) && n.ValueKind == JsonValueKind.String + ? n.GetString() ?? "unknown" : "unknown"; + throw new InvalidOperationException( + $"MCP server has incomplete configuration in registry - no package information or remote endpoints available. Server: {srvName}"); + } + + var packages = pkgsEl.EnumerateArray().ToList(); + if (packages.Count == 0) return config; + + var package = SelectBestPackage(packages); + if (package.ValueKind != JsonValueKind.Object) return config; + + var registryName = GetStr(package, "registry_name"); + var packageName = GetStr(package, "name"); + var runtimeHint = GetStr(package, "runtime_hint"); + + // Resolve environment variables + var envVars = package.TryGetProperty("environment_variables", out var evEl) && evEl.ValueKind == JsonValueKind.Array + ? evEl : (JsonElement?)null; + var resolvedEnv = envVars.HasValue + ? ProcessEnvironmentVariables(envVars.Value, envOverrides) + : new Dictionary(); + + // Process arguments + var runtimeArgs = package.TryGetProperty("runtime_arguments", out var raEl) && raEl.ValueKind == JsonValueKind.Array + ? ProcessArguments(raEl, resolvedEnv, runtimeVars) : new List(); + var packageArgs = package.TryGetProperty("package_arguments", out var paEl) && paEl.ValueKind == JsonValueKind.Array + ? ProcessArguments(paEl, resolvedEnv, runtimeVars) : new List(); + + var allArgs = runtimeArgs.Concat(packageArgs).ToList(); + + switch (registryName) + { + case "npm": + config["command"] = string.IsNullOrEmpty(runtimeHint) ? "npx" : runtimeHint; + config["args"] = new List { "-y", packageName }.Concat(allArgs.Cast()).ToList(); + break; + case "docker": + config["command"] = "docker"; + config["args"] = (runtimeArgs.Count > 0 + ? InjectEnvVarsIntoDockerArgs(runtimeArgs, resolvedEnv) + : InjectEnvVarsIntoDockerArgs(new List { "run", "-i", "--rm", packageName }, resolvedEnv)) + .Cast().ToList(); + break; + case "pypi": + config["command"] = string.IsNullOrEmpty(runtimeHint) ? "uvx" : runtimeHint; + config["args"] = new List { packageName }.Concat(allArgs.Cast()).ToList(); + break; + case "homebrew": + config["command"] = packageName.Contains('/') ? packageName.Split('/').Last() : packageName; + config["args"] = allArgs.Cast().ToList(); + break; + default: + config["command"] = string.IsNullOrEmpty(runtimeHint) ? packageName : runtimeHint; + config["args"] = allArgs.Cast().ToList(); + break; + } + + if (resolvedEnv.Count > 0) + { + var envDict = new Dictionary(); + foreach (var kvp in resolvedEnv) envDict[kvp.Key] = kvp.Value; + config["env"] = envDict; + } + + return config; + } + + private static Dictionary ProcessEnvironmentVariables( + JsonElement envVarsArray, Dictionary? envOverrides) + { + var resolved = new Dictionary(); + envOverrides ??= new Dictionary(); + var skipPrompting = envOverrides.Count > 0 + || Environment.GetEnvironmentVariable("APM_E2E_TESTS") == "1"; + + var defaultGitHubEnv = new Dictionary + { + ["GITHUB_TOOLSETS"] = "context", + ["GITHUB_DYNAMIC_TOOLSETS"] = "1" + }; + + var emptyValueVars = new HashSet(); + foreach (var kvp in envOverrides) + { + if (string.IsNullOrWhiteSpace(kvp.Value)) + emptyValueVars.Add(kvp.Key); + } + + foreach (var envVar in envVarsArray.EnumerateArray()) + { + if (envVar.ValueKind != JsonValueKind.Object) continue; + var name = GetStr(envVar, "name"); + if (string.IsNullOrEmpty(name)) continue; + + var required = !envVar.TryGetProperty("required", out var reqEl) + || reqEl.ValueKind != JsonValueKind.False; + + string? value = envOverrides.TryGetValue(name, out var ov) && !string.IsNullOrWhiteSpace(ov) ? ov : null; + value ??= Environment.GetEnvironmentVariable(name); + + if (!string.IsNullOrWhiteSpace(value)) + resolved[name] = value; + else if (emptyValueVars.Contains(name) && defaultGitHubEnv.ContainsKey(name)) + resolved[name] = defaultGitHubEnv[name]; + else if (!required && defaultGitHubEnv.ContainsKey(name)) + resolved[name] = defaultGitHubEnv[name]; + else if (skipPrompting && defaultGitHubEnv.ContainsKey(name)) + resolved[name] = defaultGitHubEnv[name]; + } + + return resolved; + } + + private static List ProcessArguments( + JsonElement argsArray, + Dictionary resolvedEnv, + Dictionary runtimeVars) + { + var processed = new List(); + foreach (var arg in argsArray.EnumerateArray()) + { + if (arg.ValueKind == JsonValueKind.Object) + { + var argType = GetStr(arg, "type"); + if (argType == "positional") + { + var value = GetStr(arg, "value"); + if (string.IsNullOrEmpty(value)) value = GetStr(arg, "default"); + if (!string.IsNullOrEmpty(value)) + processed.Add(ResolveVariablePlaceholders(value, resolvedEnv, runtimeVars)); + } + else if (argType == "named") + { + var name = GetStr(arg, "name"); + var value = GetStr(arg, "value"); + if (string.IsNullOrEmpty(value)) value = GetStr(arg, "default"); + if (!string.IsNullOrEmpty(name)) + { + processed.Add(name); + if (!string.IsNullOrEmpty(value) && value != name && !value.StartsWith("-")) + processed.Add(ResolveVariablePlaceholders(value, resolvedEnv, runtimeVars)); + } + } + } + else if (arg.ValueKind == JsonValueKind.String) + { + var val = arg.GetString() ?? ""; + processed.Add(ResolveVariablePlaceholders(val, resolvedEnv, runtimeVars)); + } + } + return processed; + } + + private static string ResolveVariablePlaceholders( + string value, Dictionary resolvedEnv, Dictionary runtimeVars) + { + if (string.IsNullOrEmpty(value)) return value; + + var processed = Regex.Replace(value, @"<([A-Z_][A-Z0-9_]*)>", m => + resolvedEnv.TryGetValue(m.Groups[1].Value, out var v) ? v : m.Value); + + processed = Regex.Replace(processed, @"\{([a-zA-Z_][a-zA-Z0-9_]*)\}", m => + runtimeVars.TryGetValue(m.Groups[1].Value, out var v) ? v : m.Value); + + return processed; + } + + private static string ResolveEnvVariable(string name, string value, Dictionary? envOverrides) + { + envOverrides ??= new Dictionary(); + var processed = value; + foreach (Match m in Regex.Matches(value, @"<([A-Z_][A-Z0-9_]*)>")) + { + var envName = m.Groups[1].Value; + var envValue = envOverrides.TryGetValue(envName, out var ov) ? ov : null; + envValue ??= Environment.GetEnvironmentVariable(envName); + if (!string.IsNullOrEmpty(envValue)) + processed = processed.Replace(m.Value, envValue); + } + return processed; + } + + private static List InjectEnvVarsIntoDockerArgs(List dockerArgs, Dictionary envVars) + { + if (envVars.Count == 0) return dockerArgs; + + var result = new List(); + var hasInteractive = dockerArgs.Contains("-i") || dockerArgs.Contains("--interactive"); + var hasRm = dockerArgs.Contains("--rm"); + + int i = 0; + while (i < dockerArgs.Count) + { + var arg = dockerArgs[i]; + result.Add(arg); + + if (arg == "run") + { + if (!hasInteractive) result.Add("-i"); + if (!hasRm) result.Add("--rm"); + } + + if (envVars.ContainsKey(arg)) + { + result.RemoveAt(result.Count - 1); + result.AddRange(new[] { "-e", $"{arg}={envVars[arg]}" }); + } + else if (arg == "-e" && i + 1 < dockerArgs.Count) + { + var next = dockerArgs[i + 1]; + if (envVars.TryGetValue(next, out var ev)) + { + result.Add($"{next}={ev}"); + i++; + } + else + { + result.Add(next); + i++; + } + } + i++; + } + + // Add remaining env vars not in template + var templateVars = new HashSet(dockerArgs.Where(a => envVars.ContainsKey(a))); + foreach (var (envName, envValue) in envVars) + { + if (!templateVars.Contains(envName)) + { + var insertPos = result.Count; + for (var idx = 0; idx < result.Count; idx++) + { + if (result[idx] == "run") + { + insertPos = Math.Min(result.Count - 1, idx + 1); + break; + } + } + result.Insert(insertPos, "-e"); + result.Insert(insertPos + 1, $"{envName}={envValue}"); + } + } + + return result; + } + + private static JsonElement SelectBestPackage(List packages) + { + var priority = new[] { "npm", "docker", "pypi", "homebrew" }; + foreach (var reg in priority) + { + foreach (var pkg in packages) + { + if (GetStr(pkg, "registry_name") == reg) + return pkg; + } + } + return packages[0]; + } + + private static string GetStr(JsonElement el, string prop, string def = "") + { + if (el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.String) + return v.GetString() ?? def; + return def; + } + + private static Dictionary ConvertCacheEntry(Dictionary entry) + { + var result = new Dictionary(); + foreach (var (key, value) in entry) + { + if (value is JsonElement je) + result[key] = je; + else + result[key] = JsonSerializationHelper.ToJsonElement(JsonSerializationHelper.ToJsonNode(value)); + } + return result; + } + + /// + /// Determine if a server is a GitHub MCP server using allowlist and hostname validation. + /// + internal static bool IsGitHubServer(string? serverName, string? url) + { + var allowedNames = new[] { "github-mcp-server", "github", "github-mcp", "github-copilot-mcp-server" }; + + if (!string.IsNullOrEmpty(serverName) && + allowedNames.Any(n => string.Equals(n, serverName, StringComparison.OrdinalIgnoreCase))) + { + return true; + } + + if (!string.IsNullOrEmpty(url)) + { + try + { + var parsed = new Uri(url); + if (parsed.Host is { Length: > 0 } host && GitHubHost.IsGitHubHostname(host)) + return true; + } + catch + { + // URL parsing failed — not a GitHub server + } + } + + return false; + } +} diff --git a/src/apm-dotnet/src/Apm.Cli/Adapters/Client/IClientAdapter.cs b/src/apm-dotnet/src/Apm.Cli/Adapters/Client/IClientAdapter.cs new file mode 100644 index 000000000..4ca0db5bb --- /dev/null +++ b/src/apm-dotnet/src/Apm.Cli/Adapters/Client/IClientAdapter.cs @@ -0,0 +1,35 @@ +namespace Apm.Cli.Adapters.Client; + +/// +/// Base interface for MCP client adapters. +/// Each adapter handles client-specific configuration for MCP servers. +/// +public interface IClientAdapter +{ + /// Get the path to the MCP configuration file. + string GetConfigPath(); + + /// Update the MCP configuration. + bool UpdateConfig(Dictionary configUpdates); + + /// Get the current MCP configuration. + Dictionary GetCurrentConfig(); + + /// + /// Configure an MCP server in the client configuration. + /// + /// URL or identifier of the MCP server. + /// Optional name for the server. + /// Whether to enable the server. + /// Environment variable overrides. + /// Pre-fetched server info to avoid duplicate registry calls. + /// Runtime variable values. + /// True if successful, false otherwise. + bool ConfigureMcpServer( + string serverUrl, + string? serverName = null, + bool enabled = true, + Dictionary? envOverrides = null, + Dictionary>? serverInfoCache = null, + Dictionary? runtimeVars = null); +} diff --git a/src/apm-dotnet/src/Apm.Cli/Adapters/Client/VSCodeClientAdapter.cs b/src/apm-dotnet/src/Apm.Cli/Adapters/Client/VSCodeClientAdapter.cs new file mode 100644 index 000000000..5d50deffb --- /dev/null +++ b/src/apm-dotnet/src/Apm.Cli/Adapters/Client/VSCodeClientAdapter.cs @@ -0,0 +1,348 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Apm.Cli.Registry; +using Apm.Cli.Utils; + +namespace Apm.Cli.Adapters.Client; + +/// +/// VSCode implementation of MCP client adapter. +/// Manages MCP server configuration via .vscode/mcp.json in the repository. +/// +public class VSCodeClientAdapter : IClientAdapter +{ + private static readonly JsonSerializerOptions WriteOptions = new() { WriteIndented = true }; + private readonly RegistryClient _registryClient; + + public VSCodeClientAdapter(string? registryUrl = null) + { + _registryClient = new RegistryClient(registryUrl); + } + + public string GetConfigPath() + { + var repoRoot = Directory.GetCurrentDirectory(); + var vscodeDir = Path.Combine(repoRoot, ".vscode"); + + try + { + if (!Directory.Exists(vscodeDir)) + Directory.CreateDirectory(vscodeDir); + } + catch (Exception ex) + { + Console.WriteLine($"Warning: Could not create .vscode directory: {ex.Message}"); + } + + return Path.Combine(vscodeDir, "mcp.json"); + } + + public bool UpdateConfig(Dictionary newConfig) + { + var configPath = GetConfigPath(); + try + { + var json = JsonSerializationHelper.DictToJsonObject(newConfig).ToJsonString(WriteOptions); + File.WriteAllText(configPath, json); + return true; + } + catch (Exception ex) + { + Console.WriteLine($"Error updating VSCode MCP configuration: {ex.Message}"); + return false; + } + } + + public Dictionary GetCurrentConfig() + { + var configPath = GetConfigPath(); + try + { + if (!File.Exists(configPath)) + return new Dictionary(); + + var text = File.ReadAllText(configPath); + var node = JsonNode.Parse(text)?.AsObject(); + if (node is null) return new Dictionary(); + + var result = new Dictionary(); + foreach (var kvp in node) + result[kvp.Key] = kvp.Value?.DeepClone(); + return result; + } + catch (Exception ex) + { + Console.WriteLine($"Error reading VSCode MCP configuration: {ex.Message}"); + return new Dictionary(); + } + } + + public bool ConfigureMcpServer( + string serverUrl, + string? serverName = null, + bool enabled = true, + Dictionary? envOverrides = null, + Dictionary>? serverInfoCache = null, + Dictionary? runtimeVars = null) + { + if (string.IsNullOrWhiteSpace(serverUrl)) + { + Console.WriteLine("Error: serverUrl cannot be empty"); + return false; + } + + try + { + Dictionary? serverInfo = null; + if (serverInfoCache != null && serverInfoCache.TryGetValue(serverUrl, out var cached)) + serverInfo = ConvertCacheEntry(cached); + else + serverInfo = _registryClient.FindServerByReference(serverUrl); + + if (serverInfo == null || serverInfo.Count == 0) + throw new ArgumentException($"Failed to retrieve server details for '{serverUrl}'. Server not found in registry."); + + var (serverConfig, inputVars) = FormatServerConfig(serverInfo); + + if (serverConfig.Count == 0) + { + Console.WriteLine($"Unable to configure server: {serverUrl}"); + return false; + } + + var configKey = serverName ?? serverUrl; + + // Work directly with JsonNode for reliable nested manipulation + var configPath = GetConfigPath(); + JsonObject root; + try + { + if (File.Exists(configPath)) + { + var text = File.ReadAllText(configPath); + root = JsonNode.Parse(text)?.AsObject() ?? new JsonObject(); + } + else + root = new JsonObject(); + } + catch { root = new JsonObject(); } + + if (root["servers"] is not JsonObject servers) + { + servers = new JsonObject(); + root["servers"] = servers; + } + if (root["inputs"] is not JsonArray inputs) + { + inputs = new JsonArray(); + root["inputs"] = inputs; + } + + servers[configKey] = JsonSerializationHelper.DictToJsonObject(serverConfig); + + var existingIds = new HashSet(); + foreach (var item in inputs) + { + if (item is JsonObject obj && obj["id"]?.GetValue() is string id) + existingIds.Add(id); + } + foreach (var iv in inputVars) + { + if (iv.TryGetValue("id", out var idVal) && idVal is string idStr && !existingIds.Contains(idStr)) + { + inputs.Add(JsonSerializationHelper.DictToJsonObject(iv)); + existingIds.Add(idStr); + } + } + + File.WriteAllText(configPath, root.ToJsonString(WriteOptions)); + Console.WriteLine($"Successfully configured MCP server '{configKey}' for VS Code"); + return true; + } + catch (ArgumentException) + { + throw; + } + catch (Exception ex) + { + Console.WriteLine($"Error configuring MCP server: {ex.Message}"); + return false; + } + } + + private static (Dictionary ServerConfig, List> InputVars) FormatServerConfig( + Dictionary serverInfo) + { + var serverConfig = new Dictionary(); + var inputVars = new List>(); + + if (serverInfo.TryGetValue("packages", out var pkgsEl) && pkgsEl.ValueKind == JsonValueKind.Array) + { + var packages = pkgsEl.EnumerateArray().ToList(); + if (packages.Count > 0) + { + var package = packages[0]; + var runtimeHint = GetStr(package, "runtime_hint"); + var registryName = GetStr(package, "registry_name").ToLowerInvariant(); + + if (runtimeHint == "npx" || registryName.Contains("npm")) + { + var args = ExtractRequiredRuntimeArgs(package); + if (args.Count == 0) + { + var name = GetStr(package, "name"); + if (!string.IsNullOrEmpty(name)) args.Add(name); + } + serverConfig["type"] = "stdio"; + serverConfig["command"] = "npx"; + serverConfig["args"] = args; + } + else if (runtimeHint == "docker") + { + var args = ExtractRequiredRuntimeArgs(package); + if (args.Count == 0) + args = new List { "run", "-i", "--rm", GetStr(package, "name") }; + serverConfig["type"] = "stdio"; + serverConfig["command"] = "docker"; + serverConfig["args"] = args; + } + else if (runtimeHint is "uvx" or "pip" or "python" + || runtimeHint.Contains("python") + || registryName == "pypi") + { + var command = runtimeHint switch + { + "uvx" => "uvx", + "python" or "pip" => "python3", + _ when runtimeHint.Contains("python") => runtimeHint, + _ => "python3" + }; + var args = ExtractRequiredRuntimeArgs(package); + if (args.Count == 0) + { + var pkgName = GetStr(package, "name"); + if (runtimeHint == "uvx") + { + var mod = pkgName.Replace("mcp-server-", ""); + args.Add($"mcp-server-{mod}"); + } + else + { + var mod = pkgName.Replace("mcp-server-", "").Replace("-", "_"); + args.Add("-m"); + args.Add($"mcp_server_{mod}"); + } + } + serverConfig["type"] = "stdio"; + serverConfig["command"] = command; + serverConfig["args"] = args; + } + + // Environment variables → input variable references + if (package.TryGetProperty("environment_variables", out var envEl) && envEl.ValueKind == JsonValueKind.Array) + { + var env = new Dictionary(); + foreach (var envVar in envEl.EnumerateArray()) + { + var name = GetStr(envVar, "name"); + if (string.IsNullOrEmpty(name)) continue; + + var inputVarName = name.ToLowerInvariant().Replace("_", "-"); + env[name] = $"${{input:{inputVarName}}}"; + + inputVars.Add(new Dictionary + { + ["type"] = "promptString", + ["id"] = inputVarName, + ["description"] = GetStr(envVar, "description", $"{name} for MCP server"), + ["password"] = true + }); + } + if (env.Count > 0) serverConfig["env"] = env; + } + } + } + + // Fallback: SSE / remotes + if (serverConfig.Count == 0) + { + if (serverInfo.TryGetValue("sse_endpoint", out var sseEl) && sseEl.ValueKind == JsonValueKind.String) + { + serverConfig["type"] = "sse"; + serverConfig["url"] = sseEl.GetString() ?? ""; + if (serverInfo.TryGetValue("sse_headers", out var hdr) && hdr.ValueKind == JsonValueKind.Object) + { + var headers = new Dictionary(); + foreach (var p in hdr.EnumerateObject()) + headers[p.Name] = p.Value.ToString(); + serverConfig["headers"] = headers; + } + } + else if (serverInfo.TryGetValue("remotes", out var remotesEl) && remotesEl.ValueKind == JsonValueKind.Array) + { + foreach (var remote in remotesEl.EnumerateArray()) + { + if (GetStr(remote, "transport_type") == "sse") + { + serverConfig["type"] = "sse"; + serverConfig["url"] = GetStr(remote, "url"); + if (remote.TryGetProperty("headers", out var rh) && rh.ValueKind == JsonValueKind.Object) + { + var headers = new Dictionary(); + foreach (var p in rh.EnumerateObject()) + headers[p.Name] = p.Value.ToString(); + serverConfig["headers"] = headers; + } + break; + } + } + } + else + { + var srvName = serverInfo.TryGetValue("name", out var n) && n.ValueKind == JsonValueKind.String + ? n.GetString() ?? "unknown" : "unknown"; + throw new ArgumentException( + $"MCP server has incomplete configuration in registry - no package information or remote endpoints available. Server: {srvName}"); + } + } + + return (serverConfig, inputVars); + } + + private static List ExtractRequiredRuntimeArgs(JsonElement package) + { + var args = new List(); + if (package.TryGetProperty("runtime_arguments", out var argsEl) && argsEl.ValueKind == JsonValueKind.Array) + { + foreach (var arg in argsEl.EnumerateArray()) + { + if (arg.TryGetProperty("is_required", out var req) && req.ValueKind == JsonValueKind.True + && arg.TryGetProperty("value_hint", out var hint) && hint.ValueKind == JsonValueKind.String) + { + args.Add(hint.GetString()); + } + } + } + return args; + } + + private static string GetStr(JsonElement el, string prop, string def = "") + { + if (el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.String) + return v.GetString() ?? def; + return def; + } + + private static Dictionary ConvertCacheEntry(Dictionary entry) + { + var result = new Dictionary(); + foreach (var (key, value) in entry) + { + if (value is JsonElement je) + result[key] = je; + else + result[key] = JsonSerializationHelper.ToJsonElement(JsonSerializationHelper.ToJsonNode(value)); + } + return result; + } +} diff --git a/src/apm-dotnet/src/Apm.Cli/Adapters/PackageManager/DefaultMcpPackageManager.cs b/src/apm-dotnet/src/Apm.Cli/Adapters/PackageManager/DefaultMcpPackageManager.cs new file mode 100644 index 000000000..cc4221e69 --- /dev/null +++ b/src/apm-dotnet/src/Apm.Cli/Adapters/PackageManager/DefaultMcpPackageManager.cs @@ -0,0 +1,97 @@ +using Apm.Cli.Adapters.Client; +using Apm.Cli.Core; + +namespace Apm.Cli.Adapters.PackageManager; + +/// +/// Default MCP package manager implementation. +/// Delegates installation to the configured client adapter. +/// +public class DefaultMcpPackageManager : IPackageManagerAdapter +{ + public bool Install(string packageName, string? version = null) + { + try + { + var adapter = CreateClientAdapter(); + var result = adapter.ConfigureMcpServer(packageName, packageName, true); + if (result) + Console.WriteLine($"Successfully installed {packageName}"); + return result; + } + catch (Exception ex) + { + Console.WriteLine($"Error installing package {packageName}: {ex.Message}"); + return false; + } + } + + public bool Uninstall(string packageName) + { + try + { + var adapter = CreateClientAdapter(); + var config = adapter.GetCurrentConfig(); + + if (config.TryGetValue("servers", out var serversObj) && + serversObj is Dictionary servers && + servers.ContainsKey(packageName)) + { + servers.Remove(packageName); + var result = adapter.UpdateConfig(config); + if (result) + Console.WriteLine($"Successfully uninstalled {packageName}"); + return result; + } + + Console.WriteLine($"Package {packageName} not found in configuration"); + return false; + } + catch (Exception ex) + { + Console.WriteLine($"Error uninstalling package {packageName}: {ex.Message}"); + return false; + } + } + + public List ListInstalled() + { + try + { + var adapter = CreateClientAdapter(); + var config = adapter.GetCurrentConfig(); + + if (config.TryGetValue("servers", out var serversObj) && + serversObj is Dictionary servers) + { + return servers.Keys.ToList(); + } + + return []; + } + catch (Exception ex) + { + Console.WriteLine($"Error retrieving installed MCP servers: {ex.Message}"); + return []; + } + } + + public List Search(string query) + { + // Placeholder: registry search integration would go here + Console.WriteLine("Warning: Package search not yet implemented in .NET port"); + return []; + } + + private static IClientAdapter CreateClientAdapter() + { + var clientType = Configuration.GetDefaultClient(); + return clientType.ToLowerInvariant() switch + { + "vscode" => new VSCodeClientAdapter(), + "codex" => new CodexClientAdapter(), + "copilot" => new CopilotClientAdapter(), + _ => new VSCodeClientAdapter(), + }; + } +} diff --git a/src/apm-dotnet/src/Apm.Cli/Adapters/PackageManager/IPackageManagerAdapter.cs b/src/apm-dotnet/src/Apm.Cli/Adapters/PackageManager/IPackageManagerAdapter.cs new file mode 100644 index 000000000..4f011d21d --- /dev/null +++ b/src/apm-dotnet/src/Apm.Cli/Adapters/PackageManager/IPackageManagerAdapter.cs @@ -0,0 +1,19 @@ +namespace Apm.Cli.Adapters.PackageManager; + +/// +/// Base interface for MCP package managers. +/// +public interface IPackageManagerAdapter +{ + /// Install an MCP package. + bool Install(string packageName, string? version = null); + + /// Uninstall an MCP package. + bool Uninstall(string packageName); + + /// List all installed MCP packages. + List ListInstalled(); + + /// Search for MCP packages. + List Search(string query); +} diff --git a/src/apm-dotnet/src/Apm.Cli/Apm.Cli.csproj b/src/apm-dotnet/src/Apm.Cli/Apm.Cli.csproj new file mode 100644 index 000000000..41436fa80 --- /dev/null +++ b/src/apm-dotnet/src/Apm.Cli/Apm.Cli.csproj @@ -0,0 +1,47 @@ + + + + Exe + net10.0 + Apm.Cli + apm + + + true + apm + true + + + apm-cli + 0.7.2 + Daniel Meppiel + The dependency manager for AI agents. Declare skills, prompts, instructions, and tools in apm.yml. + MIT + https://github.com/danielmeppiel/apm + https://github.com/danielmeppiel/apm + ai;agents;package-manager;copilot;claude;codex;mcp + README.md + + + true + linux-x64;linux-arm64;osx-x64;osx-arm64;win-x64 + true + + + + + + + + + + + + + + + + + + + diff --git a/src/apm-dotnet/src/Apm.Cli/Commands/CompileCommand.cs b/src/apm-dotnet/src/Apm.Cli/Commands/CompileCommand.cs new file mode 100644 index 000000000..f2d724e28 --- /dev/null +++ b/src/apm-dotnet/src/Apm.Cli/Commands/CompileCommand.cs @@ -0,0 +1,501 @@ +using System.ComponentModel; +using System.Security.Cryptography; +using System.Text; +using Spectre.Console; +using Spectre.Console.Cli; +using Apm.Cli.Compilation; +using Apm.Cli.Core; +using Apm.Cli.Models; +using Apm.Cli.Primitives; +using Apm.Cli.Utils; + +namespace Apm.Cli.Commands; + +public sealed class CompileSettings : CommandSettings +{ + [CommandOption("-t|--target")] + [Description("🎯 Target platform: vscode, agents, claude, or all (auto-detects if omitted)")] + public string? Target { get; set; } + + [CommandOption("--strategy")] + [Description("Compilation strategy: distributed or single-file")] + public string? Strategy { get; set; } + + [CommandOption("--single-agents")] + [Description("📄 Force single-file compilation (legacy mode)")] + public bool SingleAgents { get; set; } + + [CommandOption("--dry-run")] + [Description("🔍 Preview compilation without writing files")] + public bool DryRun { get; set; } + + [CommandOption("-v|--verbose")] + [Description("🔍 Show detailed source attribution and optimizer analysis")] + public bool Verbose { get; set; } + + [CommandOption("--trace")] + [Description("Show source attribution")] + public bool Trace { get; set; } + + [CommandOption("--local-only")] + [Description("🏠 Ignore dependencies, compile only local primitives")] + public bool LocalOnly { get; set; } + + [CommandOption("--debug")] + [Description("Show optimizer metrics")] + public bool Debug { get; set; } + + [CommandOption("--no-constitution")] + [Description("Skip constitution block")] + public bool NoConstitution { get; set; } + + [CommandOption("--chatmode")] + [Description("Target specific chatmode")] + public string? Chatmode { get; set; } + + [CommandOption("--clean")] + [Description("🧹 Remove orphaned AGENTS.md files that are no longer generated")] + public bool Clean { get; set; } + + [CommandOption("--exclude")] + [Description("Glob patterns to exclude from compilation")] + public string[]? Exclude { get; set; } + + [CommandOption("--watch")] + [Description("Watch for changes and recompile")] + public bool Watch { get; set; } + + [CommandOption("--no-links")] + [Description("Skip markdown link resolution")] + public bool NoLinks { get; set; } + + [CommandOption("--validate")] + [Description("Validate primitives without compiling")] + public bool ValidateOnly { get; set; } +} + +public sealed class CompileCommand : Command +{ + public override int Execute(CommandContext context, CompileSettings settings, CancellationToken cancellation) + { + try + { + if (!File.Exists("apm.yml")) + { + ConsoleHelpers.Error("❌ Not an APM project - no apm.yml found"); + ConsoleHelpers.Info("💡 To initialize an APM project, run:"); + ConsoleHelpers.Info(" apm init"); + return 1; + } + + // Check for content to compile + var apmDir = Path.GetFullPath(".apm"); + var apmModulesExists = Directory.Exists("apm_modules"); + var constitutionPath = Constitution.FindConstitution("."); + var constitutionExists = File.Exists(constitutionPath); + var localApmHasContent = Directory.Exists(apmDir) + && (GlobFiles(apmDir, "*.instructions.md").Any() + || GlobFiles(apmDir, "*.chatmode.md").Any()); + + if (!apmModulesExists && !localApmHasContent && !constitutionExists) + { + var hasEmptyApm = Directory.Exists(apmDir) + && !GlobFiles(apmDir, "*.instructions.md").Any() + && !GlobFiles(apmDir, "*.chatmode.md").Any(); + + if (hasEmptyApm) + { + ConsoleHelpers.Error("❌ No instruction files found in .apm/ directory"); + ConsoleHelpers.Info("💡 To add instructions, create files like:"); + ConsoleHelpers.Info(" .apm/instructions/coding-standards.instructions.md"); + ConsoleHelpers.Info(" .apm/chatmodes/backend-engineer.chatmode.md"); + } + else + { + ConsoleHelpers.Error("❌ No APM content found to compile"); + ConsoleHelpers.Info("💡 To get started:"); + ConsoleHelpers.Info(" 1. Install APM dependencies: apm install /"); + ConsoleHelpers.Info(" 2. Or create local instructions: mkdir -p .apm/instructions"); + ConsoleHelpers.Info(" 3. Then create .instructions.md or .chatmode.md files"); + } + + if (!settings.DryRun) + return 1; + } + + // Validation-only mode + if (settings.ValidateOnly) + return RunValidation(); + + // Watch mode + if (settings.Watch) + return RunWatchMode(settings, cancellation); + + ConsoleHelpers.Info("Starting context compilation...", symbol: "cogs"); + + // Auto-detect target + string? configTarget = null; + try + { + var apmPkg = ApmPackage.FromApmYml(Path.GetFullPath("apm.yml")); + configTarget = apmPkg.Target; + } + catch { /* proceed with auto-detection */ } + + var (detectedTarget, detectionReason) = TargetDetection.DetectTarget( + Directory.GetCurrentDirectory(), + settings.Target, + configTarget); + + var effectiveTarget = detectedTarget == "minimal" ? "vscode" : detectedTarget; + + // Build compilation config + var overrides = new Dictionary + { + ["chatmode"] = settings.Chatmode, + ["dry_run"] = settings.DryRun, + ["single_agents"] = settings.SingleAgents, + ["trace"] = settings.Trace || settings.Verbose, + ["local_only"] = settings.LocalOnly, + ["debug"] = settings.Debug || settings.Verbose, + ["clean_orphaned"] = settings.Clean, + ["target"] = effectiveTarget, + }; + + if (settings.NoLinks) + overrides["resolve_links"] = false; + if (settings.Strategy is not null) + overrides["strategy"] = settings.Strategy; + if (settings.Exclude is { Length: > 0 }) + overrides["exclude"] = settings.Exclude.ToList(); + + var config = CompilationConfig.FromApmYml(overrides); + config.WithConstitution = !settings.NoConstitution; + + // Display target info for distributed mode + if (config.Strategy == "distributed" && !settings.SingleAgents) + { + if (detectedTarget == "minimal") + { + ConsoleHelpers.Info($"Compiling for AGENTS.md only ({detectionReason})"); + ConsoleHelpers.Info("💡 Create .github/ or .claude/ folder for full integration", symbol: "bulb"); + } + else if (detectedTarget is "vscode" or "agents") + ConsoleHelpers.Info($"Compiling for AGENTS.md (VSCode/Copilot) - {detectionReason}"); + else if (detectedTarget == "claude") + ConsoleHelpers.Info($"Compiling for CLAUDE.md (Claude Code) - {detectionReason}"); + else + ConsoleHelpers.Info($"Compiling for AGENTS.md + CLAUDE.md - {detectionReason}"); + + if (settings.DryRun) + ConsoleHelpers.Info("Dry run mode: showing placement without writing files", symbol: "preview"); + if (settings.Verbose) + ConsoleHelpers.Info("Verbose mode: showing source attribution and optimizer analysis"); + } + else + { + ConsoleHelpers.Info("Using single-file compilation (legacy mode)"); + } + + // Compile + var compiler = new AgentsCompiler("."); + var result = compiler.Compile(config); + + if (result.Success) + { + if (config.Strategy == "distributed" && !settings.SingleAgents) + { + if (!settings.DryRun) + ConsoleHelpers.Success("Compilation completed successfully!", symbol: "check"); + } + else + { + HandleSingleFileResult(compiler, config, result, settings); + } + } + + // Warnings for single-file mode + if (config.Strategy != "distributed" || settings.SingleAgents) + { + if (result.Warnings.Count > 0) + { + ConsoleHelpers.Warning($"Compilation completed with {result.Warnings.Count} warnings:"); + foreach (var warning in result.Warnings) + AnsiConsole.MarkupLine($" [yellow]⚠️ {Markup.Escape(warning)}[/]"); + } + } + + if (result.Errors.Count > 0) + { + ConsoleHelpers.Error($"Compilation failed with {result.Errors.Count} errors:"); + foreach (var error in result.Errors) + AnsiConsole.MarkupLine($" [red]❌ {Markup.Escape(error)}[/]"); + return 1; + } + + return 0; + } + catch (Exception ex) + { + ConsoleHelpers.Error($"Error during compilation: {ex.Message}"); + return 1; + } + } + + private static int RunValidation() + { + ConsoleHelpers.Info("Validating APM context...", symbol: "gear"); + var compiler = new AgentsCompiler("."); + PrimitiveCollection primitives; + try + { + primitives = PrimitiveDiscovery.DiscoverPrimitives("."); + } + catch (Exception e) + { + ConsoleHelpers.Error($"Failed to discover primitives: {e.Message}"); + return 1; + } + + var validationErrors = compiler.ValidatePrimitives(primitives); + if (validationErrors.Count > 0) + { + ConsoleHelpers.Error($"Validation failed with {validationErrors.Count} errors"); + return 1; + } + + ConsoleHelpers.Success("All primitives validated successfully!", symbol: "sparkles"); + ConsoleHelpers.Info($"Validated {primitives.Count()} primitives:"); + ConsoleHelpers.Info($" • {primitives.Chatmodes.Count} chatmodes"); + ConsoleHelpers.Info($" • {primitives.Instructions.Count} instructions"); + ConsoleHelpers.Info($" • {primitives.Contexts.Count} contexts"); + return 0; + } + + private static void HandleSingleFileResult( + AgentsCompiler compiler, + CompilationConfig config, + CompilationResult result, + CompileSettings settings) + { + // Perform intermediate compilation for constitution injection + var intermediateConfig = new CompilationConfig + { + OutputPath = config.OutputPath, + Chatmode = config.Chatmode, + ResolveLinks = config.ResolveLinks, + DryRun = true, + WithConstitution = config.WithConstitution, + Strategy = "single-file", + }; + var intermediateResult = compiler.Compile(intermediateConfig); + + if (!intermediateResult.Success) + return; + + var injector = new ConstitutionInjector("."); + var outputPath = Path.GetFullPath(config.OutputPath); + var (finalContent, cStatus, cHash) = injector.Inject( + intermediateResult.Content, + withConstitution: config.WithConstitution, + outputPath: outputPath); + + // Compute deterministic Build ID + var lines = finalContent.Split('\n').ToList(); + var idx = lines.IndexOf(CompilationConstants.BuildIdPlaceholder); + var hashInputLines = lines.Where((_, i) => i != idx); + var hashBytes = Encoding.UTF8.GetBytes(string.Join("\n", hashInputLines)); + var buildId = Convert.ToHexString(SHA256.HashData(hashBytes))[..12].ToLowerInvariant(); + if (idx >= 0) + { + lines[idx] = $""; + finalContent = string.Join("\n", lines); + if (!finalContent.EndsWith('\n')) + finalContent += "\n"; + } + + if (!settings.DryRun) + { + if (cStatus is "CREATED" or "UPDATED" or "MISSING") + File.WriteAllText(outputPath, finalContent); + else + ConsoleHelpers.Info("No changes detected; preserving existing AGENTS.md for idempotency"); + } + + if (settings.DryRun) + ConsoleHelpers.Success("Context compilation completed successfully (dry run)", symbol: "check"); + else + ConsoleHelpers.Success($"Context compiled successfully to {outputPath}", symbol: "sparkles"); + + // Summary table + var stats = intermediateResult.Stats; + var table = new Table { Border = TableBorder.Rounded }; + table.Title = new TableTitle("Compilation Summary"); + table.AddColumn(new TableColumn("[bold white]Component[/]")); + table.AddColumn(new TableColumn("[cyan]Count[/]")); + table.AddColumn(new TableColumn("[white]Details[/]")); + + table.AddRow("Spec-kit Constitution", Markup.Escape(cStatus), Markup.Escape($"Hash: {cHash ?? "-"}")); + table.AddRow("Instructions", stats.GetValueOrDefault("instructions", 0).ToString()!, "✅ All validated"); + table.AddRow("Contexts", stats.GetValueOrDefault("contexts", 0).ToString()!, "✅ All validated"); + table.AddRow("Chatmodes", stats.GetValueOrDefault("chatmodes", 0).ToString()!, "✅ All validated"); + + string outputDetails; + try + { + var fileSize = !settings.DryRun && File.Exists(outputPath) ? new FileInfo(outputPath).Length : 0; + var sizeStr = fileSize > 0 ? $"{fileSize / 1024.0:F1}KB" : "Preview"; + outputDetails = $"{Path.GetFileName(outputPath)} ({sizeStr})"; + } + catch + { + outputDetails = Path.GetFileName(outputPath); + } + table.AddRow("Output", "✨ SUCCESS", Markup.Escape(outputDetails)); + AnsiConsole.Write(table); + + if (settings.DryRun) + { + var preview = finalContent.Length > 500 + ? finalContent[..500] + "..." + : finalContent; + ConsoleHelpers.Panel(preview, title: "📋 Generated Content Preview", borderStyle: "cyan"); + } + else + { + var nextSteps = new[] + { + $"Review the generated {config.OutputPath} file", + "Install MCP dependencies: apm install", + "Execute agentic workflows: apm run