microsoft/hve-core
Publicmirrored fromhttps://github.com/microsoft/hve-coreAvailable
scripts/plugins/Generate-Plugins.ps1
413lines · modecode
| 1 | #!/usr/bin/env pwsh |
| 2 | # Copyright (c) Microsoft Corporation. |
| 3 | # SPDX-License-Identifier: MIT |
| 4 | #Requires -Version 7.0 |
| 5 | |
| 6 | <# |
| 7 | .SYNOPSIS |
| 8 | Generates Copilot CLI plugin directories from collection manifests. |
| 9 | |
| 10 | .DESCRIPTION |
| 11 | Reads collection YAML manifests from the collections/ directory and generates |
| 12 | plugin directories under plugins/ with symlinks to source artifacts, plugin.json |
| 13 | manifests, and auto-generated README files. |
| 14 | |
| 15 | Supports generating all plugins or specific collections. Use -Refresh to |
| 16 | regenerate existing plugins (deletes and recreates). |
| 17 | |
| 18 | .PARAMETER CollectionIds |
| 19 | Optional. Array of collection IDs to generate. Generates all when omitted. |
| 20 | |
| 21 | .PARAMETER Refresh |
| 22 | Optional. Deletes and recreates existing plugin directories. |
| 23 | |
| 24 | .PARAMETER DryRun |
| 25 | Optional. Shows what would be done without making changes. |
| 26 | |
| 27 | .PARAMETER Channel |
| 28 | Optional. Release channel controlling eligible item maturities. |
| 29 | Stable includes only stable items. PreRelease includes stable, preview, |
| 30 | and experimental. Deprecated is excluded from both channels. |
| 31 | |
| 32 | .EXAMPLE |
| 33 | ./Generate-Plugins.ps1 |
| 34 | # Generates all plugins (default: all + refresh) |
| 35 | |
| 36 | .EXAMPLE |
| 37 | ./Generate-Plugins.ps1 -CollectionIds rpi,github |
| 38 | # Generates only the rpi and github plugins |
| 39 | |
| 40 | .EXAMPLE |
| 41 | ./Generate-Plugins.ps1 -DryRun |
| 42 | # Shows what would be generated without making changes |
| 43 | |
| 44 | .EXAMPLE |
| 45 | ./Generate-Plugins.ps1 -Channel Stable |
| 46 | # Generates plugins with stable-only items |
| 47 | |
| 48 | .NOTES |
| 49 | Dependencies: PowerShell-Yaml module, scripts/plugins/Modules/PluginHelpers.psm1 |
| 50 | #> |
| 51 | |
| 52 | [CmdletBinding()] |
| 53 | param( |
| 54 | [Parameter(Mandatory = $false)] |
| 55 | [string[]]$CollectionIds, |
| 56 | |
| 57 | [Parameter(Mandatory = $false)] |
| 58 | [switch]$Refresh, |
| 59 | |
| 60 | [Parameter(Mandatory = $false)] |
| 61 | [switch]$DryRun, |
| 62 | |
| 63 | [Parameter(Mandatory = $false)] |
| 64 | [ValidateSet('Stable', 'PreRelease')] |
| 65 | [string]$Channel = 'PreRelease' |
| 66 | ) |
| 67 | |
| 68 | $ErrorActionPreference = 'Stop' |
| 69 | |
| 70 | Import-Module (Join-Path $PSScriptRoot 'Modules/PluginHelpers.psm1') -Force |
| 71 | Import-Module (Join-Path $PSScriptRoot '../lib/Modules/CIHelpers.psm1') -Force |
| 72 | |
| 73 | #region Orchestration |
| 74 | |
| 75 | function Get-AllowedCollectionMaturities { |
| 76 | <# |
| 77 | .SYNOPSIS |
| 78 | Returns allowed collection item maturities for a channel. |
| 79 | |
| 80 | .PARAMETER Channel |
| 81 | Release channel ('Stable' or 'PreRelease'). |
| 82 | |
| 83 | .OUTPUTS |
| 84 | [string[]] Allowed maturity values for collection items. |
| 85 | #> |
| 86 | [CmdletBinding()] |
| 87 | [OutputType([string[]])] |
| 88 | param( |
| 89 | [Parameter(Mandatory = $true)] |
| 90 | [ValidateSet('Stable', 'PreRelease')] |
| 91 | [string]$Channel |
| 92 | ) |
| 93 | |
| 94 | if ($Channel -eq 'Stable') { |
| 95 | return @('stable') |
| 96 | } |
| 97 | |
| 98 | return @('stable', 'preview', 'experimental') |
| 99 | } |
| 100 | |
| 101 | function Select-CollectionItemsByChannel { |
| 102 | <# |
| 103 | .SYNOPSIS |
| 104 | Filters collection items by channel using item maturity metadata. |
| 105 | |
| 106 | .PARAMETER Collection |
| 107 | Collection manifest hashtable. |
| 108 | |
| 109 | .PARAMETER Channel |
| 110 | Release channel ('Stable' or 'PreRelease'). |
| 111 | |
| 112 | .OUTPUTS |
| 113 | [hashtable] Collection clone with filtered items. |
| 114 | #> |
| 115 | [CmdletBinding()] |
| 116 | [OutputType([hashtable])] |
| 117 | param( |
| 118 | [Parameter(Mandatory = $true)] |
| 119 | [hashtable]$Collection, |
| 120 | |
| 121 | [Parameter(Mandatory = $true)] |
| 122 | [ValidateSet('Stable', 'PreRelease')] |
| 123 | [string]$Channel |
| 124 | ) |
| 125 | |
| 126 | $allowedMaturities = Get-AllowedCollectionMaturities -Channel $Channel |
| 127 | $filteredItems = @() |
| 128 | |
| 129 | foreach ($item in $Collection.items) { |
| 130 | $effectiveMaturity = Resolve-CollectionItemMaturity -Maturity $item.maturity |
| 131 | if ($allowedMaturities -contains $effectiveMaturity) { |
| 132 | $filteredItems += $item |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | $filteredCollection = @{} |
| 137 | foreach ($key in $Collection.Keys) { |
| 138 | $filteredCollection[$key] = $Collection[$key] |
| 139 | } |
| 140 | $filteredCollection['items'] = $filteredItems |
| 141 | |
| 142 | return $filteredCollection |
| 143 | } |
| 144 | |
| 145 | function Invoke-PluginGeneration { |
| 146 | <# |
| 147 | .SYNOPSIS |
| 148 | Orchestrates plugin directory generation from collection manifests. |
| 149 | |
| 150 | .DESCRIPTION |
| 151 | Loads collection manifests from the collections/ directory, optionally |
| 152 | filters to specified IDs, and generates plugin directory structures |
| 153 | under plugins/. Each plugin receives symlinks to source artifacts, |
| 154 | a plugin.json manifest, and an auto-generated README. |
| 155 | |
| 156 | .PARAMETER RepoRoot |
| 157 | Absolute path to the repository root directory. |
| 158 | |
| 159 | .PARAMETER CollectionIds |
| 160 | Optional. Array of collection IDs to generate. Generates all when omitted. |
| 161 | |
| 162 | .PARAMETER Refresh |
| 163 | When specified, removes existing plugin directories before regenerating. |
| 164 | |
| 165 | .PARAMETER DryRun |
| 166 | When specified, logs actions without creating files or directories. |
| 167 | |
| 168 | .PARAMETER Channel |
| 169 | Release channel controlling item maturity eligibility. |
| 170 | |
| 171 | .OUTPUTS |
| 172 | Hashtable with Success, PluginCount, and ErrorMessage keys |
| 173 | via New-GenerateResult. |
| 174 | #> |
| 175 | [CmdletBinding()] |
| 176 | [OutputType([hashtable])] |
| 177 | param( |
| 178 | [Parameter(Mandatory = $true)] |
| 179 | [ValidateNotNullOrEmpty()] |
| 180 | [string]$RepoRoot, |
| 181 | |
| 182 | [Parameter(Mandatory = $false)] |
| 183 | [string[]]$CollectionIds, |
| 184 | |
| 185 | [Parameter(Mandatory = $false)] |
| 186 | [switch]$Refresh, |
| 187 | |
| 188 | [Parameter(Mandatory = $false)] |
| 189 | [switch]$DryRun, |
| 190 | |
| 191 | [Parameter(Mandatory = $false)] |
| 192 | [ValidateSet('Stable', 'PreRelease')] |
| 193 | [string]$Channel = 'PreRelease' |
| 194 | ) |
| 195 | |
| 196 | $collectionsDir = Join-Path -Path $RepoRoot -ChildPath 'collections' |
| 197 | $pluginsDir = Join-Path -Path $RepoRoot -ChildPath 'plugins' |
| 198 | |
| 199 | # Read repo version from package.json for plugin manifests |
| 200 | $packageJsonPath = Join-Path -Path $RepoRoot -ChildPath 'package.json' |
| 201 | $repoVersion = (Get-Content -Path $packageJsonPath -Raw | ConvertFrom-Json).version |
| 202 | |
| 203 | # Auto-update hve-core-all collection with discovered artifacts |
| 204 | $updateResult = Update-HveCoreAllCollection -RepoRoot $RepoRoot -DryRun:$DryRun |
| 205 | Write-Verbose "hve-core-all updated: $($updateResult.ItemCount) items ($($updateResult.AddedCount) added, $($updateResult.RemovedCount) removed)" |
| 206 | |
| 207 | # Probe symlink capability once for the entire generation run |
| 208 | $symlinkCapable = Test-SymlinkCapability |
| 209 | Write-Verbose "Symlink capability: $symlinkCapable ($(if ($symlinkCapable) { 'using symlinks' } else { 'using file copies' }))" |
| 210 | |
| 211 | # Load all collection manifests |
| 212 | $allCollections = Get-AllCollections -CollectionsDir $collectionsDir |
| 213 | |
| 214 | if ($allCollections.Count -eq 0) { |
| 215 | Write-Warning 'No collection manifests found in collections/' |
| 216 | return New-GenerateResult -Success $true -PluginCount 0 |
| 217 | } |
| 218 | |
| 219 | # Filter to requested IDs when provided |
| 220 | if ($CollectionIds -and $CollectionIds.Count -gt 0) { |
| 221 | $filtered = @($allCollections | Where-Object { $CollectionIds -contains $_.id }) |
| 222 | $missing = @($CollectionIds | Where-Object { $_ -notin ($allCollections | ForEach-Object { $_.id }) }) |
| 223 | if ($missing.Count -gt 0) { |
| 224 | Write-Warning "Collections not found: $($missing -join ', ')" |
| 225 | } |
| 226 | $allCollections = $filtered |
| 227 | } |
| 228 | |
| 229 | Write-Host "`n=== Plugin Generation ===" -ForegroundColor Cyan |
| 230 | Write-Host "Collections: $($allCollections.Count)" |
| 231 | Write-Host "Channel: $Channel" |
| 232 | Write-Host "Plugins dir: $pluginsDir" |
| 233 | if ($DryRun) { |
| 234 | Write-Host '[DRY RUN] No changes will be made' -ForegroundColor Yellow |
| 235 | } |
| 236 | |
| 237 | $generated = 0 |
| 238 | $totalAgents = 0 |
| 239 | $totalCommands = 0 |
| 240 | $totalInstructions = 0 |
| 241 | $totalSkills = 0 |
| 242 | |
| 243 | foreach ($collection in $allCollections) { |
| 244 | $id = $collection.id |
| 245 | $pluginDir = Join-Path -Path $pluginsDir -ChildPath $id |
| 246 | |
| 247 | # Skip deprecated collections |
| 248 | $collectionMaturity = if ($collection.ContainsKey('maturity') -and $collection.maturity) { |
| 249 | [string]$collection.maturity |
| 250 | } else { 'stable' } |
| 251 | |
| 252 | if ($collectionMaturity -eq 'deprecated') { |
| 253 | Write-Verbose "Skipping deprecated collection: $id" |
| 254 | continue |
| 255 | } |
| 256 | |
| 257 | # Refresh: remove existing plugin directory |
| 258 | if ($Refresh -and (Test-Path -Path $pluginDir)) { |
| 259 | if ($DryRun) { |
| 260 | Write-Host " [DRY RUN] Would remove $pluginDir" -ForegroundColor Yellow |
| 261 | } |
| 262 | else { |
| 263 | Remove-Item -Path $pluginDir -Recurse -Force |
| 264 | Write-Verbose "Removed existing plugin directory: $pluginDir" |
| 265 | } |
| 266 | } |
| 267 | |
| 268 | # Generate plugin directory structure |
| 269 | $filteredCollection = Select-CollectionItemsByChannel -Collection $collection -Channel $Channel |
| 270 | |
| 271 | $result = Write-PluginDirectory -Collection $filteredCollection ` |
| 272 | -PluginsDir $pluginsDir ` |
| 273 | -RepoRoot $RepoRoot ` |
| 274 | -Version $repoVersion ` |
| 275 | -Maturity $collectionMaturity ` |
| 276 | -DryRun:$DryRun ` |
| 277 | -SymlinkCapable:$symlinkCapable |
| 278 | |
| 279 | $itemCount = $filteredCollection.items.Count |
| 280 | $totalAgents += $result.AgentCount |
| 281 | $totalCommands += $result.CommandCount |
| 282 | $totalInstructions += $result.InstructionCount |
| 283 | $totalSkills += $result.SkillCount |
| 284 | $generated++ |
| 285 | |
| 286 | Write-Host " $id ($itemCount items)" -ForegroundColor Green |
| 287 | } |
| 288 | |
| 289 | # Generate marketplace.json from all collections |
| 290 | Write-MarketplaceManifest ` |
| 291 | -RepoRoot $RepoRoot ` |
| 292 | -Collections $allCollections ` |
| 293 | -DryRun:$DryRun |
| 294 | |
| 295 | # Fix git index modes for text stubs on non-symlink systems so Linux |
| 296 | # checkouts materialize real symbolic links instead of plain files. |
| 297 | if (-not $symlinkCapable) { |
| 298 | $fixedCount = Repair-PluginSymlinkIndex -PluginsDir $pluginsDir -RepoRoot $RepoRoot -DryRun:$DryRun |
| 299 | if ($fixedCount -gt 0) { |
| 300 | Write-Host " Symlink index: $fixedCount entries fixed (100644 -> 120000)" -ForegroundColor Green |
| 301 | } |
| 302 | } |
| 303 | |
| 304 | Write-Host "`n--- Summary ---" -ForegroundColor Cyan |
| 305 | Write-Host " Plugins generated: $generated" |
| 306 | Write-Host " Agents: $totalAgents" |
| 307 | Write-Host " Commands: $totalCommands" |
| 308 | Write-Host " Instructions: $totalInstructions" |
| 309 | Write-Host " Skills: $totalSkills" |
| 310 | |
| 311 | return New-GenerateResult -Success $true -PluginCount $generated |
| 312 | } |
| 313 | |
| 314 | #endregion Orchestration |
| 315 | |
| 316 | #region Main Execution |
| 317 | |
| 318 | function Start-PluginGeneration { |
| 319 | <# |
| 320 | .SYNOPSIS |
| 321 | Entry point for CLI invocation. Returns 0 on success, 1 on failure. |
| 322 | |
| 323 | .PARAMETER ScriptPath |
| 324 | Absolute path to this script file, used to resolve the repo root. |
| 325 | |
| 326 | .PARAMETER CollectionIds |
| 327 | Optional collection IDs forwarded to Invoke-PluginGeneration. |
| 328 | |
| 329 | .PARAMETER Refresh |
| 330 | Forwarded refresh switch. |
| 331 | |
| 332 | .PARAMETER DryRun |
| 333 | Forwarded dry-run switch. |
| 334 | |
| 335 | .PARAMETER Channel |
| 336 | Forwarded channel parameter. |
| 337 | |
| 338 | .OUTPUTS |
| 339 | [int] Exit code: 0 for success, 1 for failure. |
| 340 | #> |
| 341 | [CmdletBinding()] |
| 342 | [OutputType([int])] |
| 343 | param( |
| 344 | [Parameter(Mandatory = $true)] |
| 345 | [string]$ScriptPath, |
| 346 | |
| 347 | [Parameter(Mandatory = $false)] |
| 348 | [string[]]$CollectionIds, |
| 349 | |
| 350 | [Parameter(Mandatory = $false)] |
| 351 | [switch]$Refresh, |
| 352 | |
| 353 | [Parameter(Mandatory = $false)] |
| 354 | [switch]$DryRun, |
| 355 | |
| 356 | [Parameter(Mandatory = $false)] |
| 357 | [ValidateSet('Stable', 'PreRelease')] |
| 358 | [string]$Channel = 'PreRelease' |
| 359 | ) |
| 360 | |
| 361 | try { |
| 362 | # Verify PowerShell-Yaml module |
| 363 | if (-not (Get-Module -ListAvailable -Name PowerShell-Yaml)) { |
| 364 | throw "Required module 'PowerShell-Yaml' is not installed." |
| 365 | } |
| 366 | Import-Module PowerShell-Yaml -ErrorAction Stop |
| 367 | |
| 368 | # Resolve paths |
| 369 | $ScriptDir = Split-Path -Parent $ScriptPath |
| 370 | $RepoRoot = (Get-Item "$ScriptDir/../..").FullName |
| 371 | |
| 372 | Write-Host 'HVE Core Plugin Generator' -ForegroundColor Cyan |
| 373 | Write-Host '==========================' -ForegroundColor Cyan |
| 374 | |
| 375 | # Default to all + refresh when no args |
| 376 | $effectiveRefresh = $Refresh |
| 377 | if (-not $CollectionIds -and -not $Refresh.IsPresent -and -not $DryRun.IsPresent) { |
| 378 | $effectiveRefresh = [switch]::new($true) |
| 379 | } |
| 380 | |
| 381 | $result = Invoke-PluginGeneration ` |
| 382 | -RepoRoot $RepoRoot ` |
| 383 | -CollectionIds $CollectionIds ` |
| 384 | -Refresh:$effectiveRefresh ` |
| 385 | -DryRun:$DryRun ` |
| 386 | -Channel $Channel |
| 387 | |
| 388 | if (-not $result.Success) { |
| 389 | throw $result.ErrorMessage |
| 390 | } |
| 391 | |
| 392 | Write-Host '' |
| 393 | Write-Host 'Done!' -ForegroundColor Green |
| 394 | Write-Host " $($result.PluginCount) plugin(s) generated." |
| 395 | |
| 396 | return 0 |
| 397 | } |
| 398 | catch { |
| 399 | Write-Error "Plugin generation failed: $($_.Exception.Message)" |
| 400 | Write-CIAnnotation -Message $_.Exception.Message -Level Error |
| 401 | return 1 |
| 402 | } |
| 403 | } |
| 404 | |
| 405 | if ($MyInvocation.InvocationName -ne '.') { |
| 406 | exit (Start-PluginGeneration ` |
| 407 | -ScriptPath $MyInvocation.MyCommand.Path ` |
| 408 | -CollectionIds $CollectionIds ` |
| 409 | -Refresh:$Refresh ` |
| 410 | -DryRun:$DryRun ` |
| 411 | -Channel $Channel) |
| 412 | } |
| 413 | #endregion |
| 414 | |