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