diff --git a/.github/workflows/build-release-dotnet.yml b/.github/workflows/build-release-dotnet.yml new file mode 100644 index 000000000..8e6986236 --- /dev/null +++ b/.github/workflows/build-release-dotnet.yml @@ -0,0 +1,488 @@ +name: .NET CI/CD Pipeline + +on: + push: + branches: [ main ] + tags: [ "v*" ] + paths-ignore: + - 'docs/**' + - '.gitignore' + - 'LICENSE' + pull_request: + branches: [ main ] + paths-ignore: + - 'docs/**' + - '.gitignore' + - 'LICENSE' + schedule: + # Run daily at 04:00 UTC + - cron: '0 4 * * *' + workflow_dispatch: + inputs: + test_build: + description: 'Test build (uploads as workflow artifacts instead of release assets)' + required: false + default: true + type: boolean + +env: + DOTNET_VERSION: '10.0.x' + PROJECT_DIR: src/apm-dotnet + +jobs: + # Always run tests first with matrix strategy + test: + name: Test + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-24.04 + arch: x86_64 + platform: linux + - os: ubuntu-24.04-arm + arch: arm64 + platform: linux + - os: macos-15-intel + arch: x86_64 + platform: darwin + - os: macos-latest + arch: arm64 + platform: darwin + - os: windows-latest + arch: x64 + platform: windows + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Restore + run: dotnet restore + working-directory: ${{ env.PROJECT_DIR }} + + - name: Build + run: dotnet build --no-restore --configuration Release + working-directory: ${{ env.PROJECT_DIR }} + + - name: Test + run: dotnet test --no-build --configuration Release --verbosity normal + working-directory: ${{ env.PROJECT_DIR }} + + # Build NativeAOT binaries + NuGet package + build: + name: Build (${{ matrix.artifact }}) + needs: [test] + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-24.04 + rid: linux-x64 + platform: linux + artifact: apm-linux-x64 + - os: ubuntu-24.04-arm + rid: linux-arm64 + platform: linux + artifact: apm-linux-arm64 + - os: macos-15-intel + rid: osx-x64 + platform: darwin + artifact: apm-osx-x64 + - os: macos-latest + rid: osx-arm64 + platform: darwin + artifact: apm-osx-arm64 + - os: windows-latest + rid: win-x64 + platform: windows + artifact: apm-win-x64 + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Publish NativeAOT (${{ matrix.rid }}) + run: > + dotnet publish src/Apm.Cli/Apm.Cli.csproj + --configuration Release + --runtime ${{ matrix.rid }} + --output ./publish/${{ matrix.artifact }} + -p:NativeAot=true + -p:PublishTrimmed=true + -p:StripSymbols=true + working-directory: ${{ env.PROJECT_DIR }} + + - name: Create tarball (Linux/macOS) + if: runner.os != 'Windows' + run: | + cd publish + tar -czf ${{ matrix.artifact }}.tar.gz ${{ matrix.artifact }} + if command -v sha256sum &> /dev/null; then + sha256sum ${{ matrix.artifact }}.tar.gz > ${{ matrix.artifact }}.tar.gz.sha256 + elif command -v shasum &> /dev/null; then + shasum -a 256 ${{ matrix.artifact }}.tar.gz > ${{ matrix.artifact }}.tar.gz.sha256 + fi + working-directory: ${{ env.PROJECT_DIR }} + + - name: Create zip (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + cd publish + Compress-Archive -Path ${{ matrix.artifact }} -DestinationPath ${{ matrix.artifact }}.zip + $hash = (Get-FileHash ${{ matrix.artifact }}.zip -Algorithm SHA256).Hash.ToLower() + "$hash ${{ matrix.artifact }}.zip" | Out-File -Encoding ascii ${{ matrix.artifact }}.zip.sha256 + working-directory: ${{ env.PROJECT_DIR }} + + - name: Upload binary artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: | + ${{ env.PROJECT_DIR }}/publish/${{ matrix.artifact }}.tar.gz + ${{ env.PROJECT_DIR }}/publish/${{ matrix.artifact }}.tar.gz.sha256 + ${{ env.PROJECT_DIR }}/publish/${{ matrix.artifact }}.zip + ${{ env.PROJECT_DIR }}/publish/${{ matrix.artifact }}.zip.sha256 + ${{ env.PROJECT_DIR }}/publish/${{ matrix.artifact }}/ + if-no-files-found: error + retention-days: 30 + + # NuGet package (only needs one platform) + pack-nuget: + name: Pack NuGet + needs: [test] + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Build Release + run: dotnet build --configuration Release + working-directory: ${{ env.PROJECT_DIR }} + + - name: Pack NuGet + run: dotnet pack --configuration Release --output ./nupkg + working-directory: ${{ env.PROJECT_DIR }} + + - name: Upload NuGet package + uses: actions/upload-artifact@v4 + with: + name: nuget-package + path: ${{ env.PROJECT_DIR }}/nupkg/*.nupkg + retention-days: 30 + + # Integration tests with binary artifact + integration-tests: + name: Integration Tests (${{ matrix.artifact }}) + needs: [test, build] + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-24.04 + artifact: apm-linux-x64 + platform: linux + binary: apm + - os: ubuntu-24.04-arm + artifact: apm-linux-arm64 + platform: linux + binary: apm + - os: macos-15-intel + artifact: apm-osx-x64 + platform: darwin + binary: apm + - os: macos-latest + artifact: apm-osx-arm64 + platform: darwin + binary: apm + - os: windows-latest + artifact: apm-win-x64 + platform: windows + binary: apm.exe + + runs-on: ${{ matrix.os }} + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download APM binary from build artifacts + uses: actions/download-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: ./artifact + + - name: Run integration tests (Linux/macOS) + if: runner.os != 'Windows' + env: + GITHUB_TOKEN: ${{ secrets.GH_MODELS_PAT }} + GITHUB_APM_PAT: ${{ secrets.GH_CLI_PAT }} + ADO_APM_PAT: ${{ secrets.ADO_APM_PAT }} + run: | + BINARY="./artifact/publish/${{ matrix.artifact }}/${{ matrix.binary }}" + chmod +x "$BINARY" + + echo "=== Version check ===" + "$BINARY" --version + + echo "=== Init test project ===" + mkdir -p /tmp/apm-integration-test + cd /tmp/apm-integration-test + "$GITHUB_WORKSPACE/artifact/publish/${{ matrix.artifact }}/${{ matrix.binary }}" init test-project + cd test-project + + echo "=== Compile dry-run ===" + "$GITHUB_WORKSPACE/artifact/publish/${{ matrix.artifact }}/${{ matrix.binary }}" compile --dry-run + + echo "=== Integration tests passed ===" + timeout-minutes: 20 + + - name: Run integration tests (Windows) + if: runner.os == 'Windows' + shell: pwsh + env: + GITHUB_TOKEN: ${{ secrets.GH_MODELS_PAT }} + GITHUB_APM_PAT: ${{ secrets.GH_CLI_PAT }} + ADO_APM_PAT: ${{ secrets.ADO_APM_PAT }} + run: | + $Binary = ".\artifact\publish\${{ matrix.artifact }}\${{ matrix.binary }}" + + Write-Host "=== Version check ===" + & $Binary --version + + Write-Host "=== Init test project ===" + $TestDir = Join-Path $env:TEMP "apm-integration-test" + New-Item -ItemType Directory -Force -Path $TestDir | Out-Null + Set-Location $TestDir + & "$env:GITHUB_WORKSPACE\artifact\publish\${{ matrix.artifact }}\${{ matrix.binary }}" init test-project + Set-Location test-project + + Write-Host "=== Compile dry-run ===" + & "$env:GITHUB_WORKSPACE\artifact\publish\${{ matrix.artifact }}\${{ matrix.binary }}" compile --dry-run + + Write-Host "=== Integration tests passed ===" + timeout-minutes: 20 + + # Release validation - isolated binary testing without source checkout + release-validation: + name: Release Validation (${{ matrix.artifact }}) + needs: [test, build, integration-tests] + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-24.04 + artifact: apm-linux-x64 + platform: linux + binary: apm + - os: ubuntu-24.04-arm + artifact: apm-linux-arm64 + platform: linux + binary: apm + - os: macos-15-intel + artifact: apm-osx-x64 + platform: darwin + binary: apm + - os: macos-latest + artifact: apm-osx-arm64 + platform: darwin + binary: apm + - os: windows-latest + artifact: apm-win-x64 + platform: windows + binary: apm.exe + + runs-on: ${{ matrix.os }} + permissions: + contents: read + + steps: + - name: Set isolated test path + id: paths + shell: bash + run: | + if [ "$RUNNER_OS" = "Windows" ]; then + echo "isolated_dir=$RUNNER_TEMP/apm-isolated-test" >> $GITHUB_OUTPUT + else + echo "isolated_dir=/tmp/apm-isolated-test" >> $GITHUB_OUTPUT + fi + + - name: Download APM binary from build artifacts + uses: actions/download-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: ${{ steps.paths.outputs.isolated_dir }} + + - name: Validate binary in isolation (Linux/macOS) + if: runner.os != 'Windows' + env: + GITHUB_TOKEN: ${{ secrets.GH_MODELS_PAT }} + GITHUB_APM_PAT: ${{ secrets.GH_CLI_PAT }} + ADO_APM_PAT: ${{ secrets.ADO_APM_PAT }} + run: | + cd "${{ steps.paths.outputs.isolated_dir }}" + BINARY="./publish/${{ matrix.artifact }}/${{ matrix.binary }}" + chmod +x "$BINARY" + + echo "=== Verify isolation (no source checkout) ===" + [ ! -f "pyproject.toml" ] && echo "✅ No source code present" || (echo "❌ Source code found" && exit 1) + + echo "=== Version check ===" + "$BINARY" --version + + echo "=== Create test project ===" + "$BINARY" init release-validation-project + cd release-validation-project + + echo "=== Compile dry-run ===" + "../$BINARY" compile --dry-run + + echo "=== Release validation passed ===" + timeout-minutes: 20 + + - name: Validate binary in isolation (Windows) + if: runner.os == 'Windows' + shell: pwsh + env: + GITHUB_TOKEN: ${{ secrets.GH_MODELS_PAT }} + GITHUB_APM_PAT: ${{ secrets.GH_CLI_PAT }} + ADO_APM_PAT: ${{ secrets.ADO_APM_PAT }} + run: | + $IsolatedDir = "${{ steps.paths.outputs.isolated_dir }}" + Set-Location $IsolatedDir + $Binary = Join-Path $IsolatedDir "publish/${{ matrix.artifact }}/${{ matrix.binary }}" + + Write-Host "=== Verify isolation (no source checkout) ===" + if (Test-Path "pyproject.toml") { + Write-Host "❌ Source code found"; exit 1 + } else { + Write-Host "✅ No source code present" + } + + Write-Host "=== Version check ===" + & $Binary --version + + Write-Host "=== Create test project ===" + & $Binary init release-validation-project + Set-Location release-validation-project + + Write-Host "=== Compile dry-run ===" + & $Binary compile --dry-run + + Write-Host "=== Release validation passed ===" + timeout-minutes: 20 + + # Publish GitHub Release + NuGet + publish-release: + name: Publish GitHub Release & NuGet + needs: [test, build, pack-nuget, integration-tests, release-validation] + if: github.ref_type == 'tag' + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + is_prerelease: ${{ steps.release_type.outputs.is_prerelease }} + is_private_repo: ${{ github.event.repository.private }} + + steps: + - name: Download all build artifacts + uses: actions/download-artifact@v4 + with: + path: ./dist + pattern: apm-* + merge-multiple: false + + - name: Prepare release binaries + run: | + echo "Downloaded structure:" + find ./dist -type f | head -50 + echo "" + + mkdir -p ./release + + # Process Unix artifacts (tar.gz) + for artifact in apm-linux-x64 apm-linux-arm64 apm-osx-x64 apm-osx-arm64; do + tarball="./dist/${artifact}/publish/${artifact}.tar.gz" + checksum="./dist/${artifact}/publish/${artifact}.tar.gz.sha256" + if [ -f "$tarball" ]; then + cp "$tarball" "./release/" + [ -f "$checksum" ] && cp "$checksum" "./release/" + echo "Prepared ${artifact}.tar.gz" + else + echo "ERROR: $tarball not found" + exit 1 + fi + done + + # Process Windows artifact (zip) + for artifact in apm-win-x64; do + zipfile="./dist/${artifact}/publish/${artifact}.zip" + checksum="./dist/${artifact}/publish/${artifact}.zip.sha256" + if [ -f "$zipfile" ]; then + cp "$zipfile" "./release/" + [ -f "$checksum" ] && cp "$checksum" "./release/" + echo "Prepared ${artifact}.zip" + else + echo "ERROR: $zipfile not found" + exit 1 + fi + done + + - name: Determine release type + id: release_type + run: | + TAG_NAME="${{ github.ref_name }}" + if [[ "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "is_prerelease=false" >> $GITHUB_OUTPUT + echo "Detected stable release: $TAG_NAME" + else + echo "is_prerelease=true" >> $GITHUB_OUTPUT + echo "Detected prerelease: $TAG_NAME" + fi + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + prerelease: ${{ steps.release_type.outputs.is_prerelease == 'true' }} + files: | + ./release/*.tar.gz + ./release/*.tar.gz.sha256 + ./release/*.zip + ./release/*.zip.sha256 + + - name: Download NuGet package + uses: actions/download-artifact@v4 + with: + name: nuget-package + path: ./nupkg + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Push to NuGet.org + if: steps.release_type.outputs.is_prerelease == 'false' + run: dotnet nuget push ./nupkg/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate 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/docs/cli-reference.md b/docs/cli-reference.md index 3f7d31c4c..9215103f1 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -545,8 +545,20 @@ apm run debug --param service=api # Run specific scripts with parameters apm run llm --param service=api --param environment=prod + +# Auto-install and run a virtual package (zero-config) +apm run owner/repo/prompts/code-review.prompt.md +apm run owner/repo/review # .prompt.md is added automatically ``` +**Auto-Install Virtual Packages:** + +When running a virtual package reference (e.g., `owner/repo/path/file.prompt.md`), APM will automatically download and install the package if it's not already available locally. This enables zero-config usage — no `apm init` or `apm install` required. APM will: +1. Create a minimal `apm.yml` if none exists +2. Download the package from GitHub +3. Add it to `apm.yml` dependencies +4. Execute the prompt + **Return Codes:** - `0` - Success - `1` - Execution failed or error occurred @@ -607,10 +619,14 @@ apm compile [OPTIONS] **Options:** - `-o, --output TEXT` - Output file path (default: AGENTS.md) - `-t, --target TEXT` - Target agent format: `vscode`, `claude`, or `all`. Auto-detects if not specified. +- `--single-agents` - Force single-file compilation (legacy mode) - `--chatmode TEXT` - Chatmode to prepend to the AGENTS.md file - `--dry-run` - Generate content without writing file +- `-v, --verbose` - Show detailed source attribution and optimizer analysis +- `--local-only` - Ignore dependencies, compile only local primitives - `--no-links` - Skip markdown link resolution - `--with-constitution/--no-constitution` - Include Spec Kit `memory/constitution.md` verbatim at top inside a delimited block (default: `--with-constitution`). When disabled, any existing block is preserved but not regenerated. +- `--clean` - Remove orphaned AGENTS.md files that are no longer generated - `--watch` - Auto-regenerate on changes (file system monitoring) - `--validate` - Validate context without compiling 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..86bf08bdf --- /dev/null +++ b/src/apm-dotnet/src/Apm.Cli/Adapters/PackageManager/DefaultMcpPackageManager.cs @@ -0,0 +1,110 @@ +using System.Text.Json.Nodes; +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(); + var servers = GetServersSection(config); + + if (servers is not null && 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(); + var servers = GetServersSection(config); + + return servers?.Select(kvp => kvp.Key).ToList() ?? []; + } + 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 []; + } + + /// + /// Extract the servers section from config, handling both Dictionary and JsonObject values. + /// GetCurrentConfig() returns JsonNode values from JSON parsing, so we handle both types. + /// + private static JsonObject? GetServersSection(Dictionary config) + { + if (!config.TryGetValue("servers", out var serversObj) || serversObj is null) + return null; + + if (serversObj is JsonObject jsonObj) + return jsonObj; + + if (serversObj is JsonNode jsonNode) + return jsonNode.AsObject(); + + return null; + } + + 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