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