microsoft/hve-core

Public

mirrored fromhttps://github.com/microsoft/hve-coreAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
hve-core-v3.3.41

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

scripts/plugins/Generate-Plugins.ps1

444lines · 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()]
53param(
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
70Import-Module (Join-Path $PSScriptRoot 'Modules/PluginHelpers.psm1') -Force
71Import-Module (Join-Path $PSScriptRoot '../collections/Modules/CollectionHelpers.psm1') -Force
72Import-Module (Join-Path $PSScriptRoot '../lib/Modules/CIHelpers.psm1') -Force
73
74#region Orchestration
75
76function 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
102function 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
146function 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 # Generate plugin directory structure (overwrites in place)
259 $filteredCollection = Select-CollectionItemsByChannel -Collection $collection -Channel $Channel
260
261 $result = Write-PluginDirectory -Collection $filteredCollection `
262 -PluginsDir $pluginsDir `
263 -RepoRoot $RepoRoot `
264 -Version $repoVersion `
265 -Maturity $collectionMaturity `
266 -DryRun:$DryRun `
267 -SymlinkCapable:$symlinkCapable
268
269 # Orphan cleanup in Refresh mode
270 if ($Refresh -and (Test-Path -LiteralPath $pluginDir)) {
271 $generatedFiles = $result.GeneratedFiles
272 $existingFiles = [System.Collections.Generic.List[string]]::new()
273 $scanQueue = [System.Collections.Generic.Queue[string]]::new()
274 $scanQueue.Enqueue($pluginDir)
275 while ($scanQueue.Count -gt 0) {
276 $currentDir = $scanQueue.Dequeue()
277 foreach ($entry in Get-ChildItem -LiteralPath $currentDir -Force) {
278 if ($entry.PSIsContainer -and -not $entry.LinkType) {
279 $scanQueue.Enqueue($entry.FullName)
280 }
281 else {
282 $existingFiles.Add($entry.FullName)
283 }
284 }
285 }
286 foreach ($existingFile in $existingFiles) {
287 if (-not $generatedFiles.Contains($existingFile)) {
288 if ($DryRun) {
289 Write-Host " [DRY RUN] Would remove orphan: $existingFile" -ForegroundColor Yellow
290 }
291 else {
292 Remove-Item -LiteralPath $existingFile -Force -ErrorAction Stop
293 Write-Verbose "Removed orphan file: $existingFile"
294 }
295 }
296 }
297 # Remove empty directories bottom-up
298 if (-not $DryRun) {
299 Get-ChildItem -LiteralPath $pluginDir -Recurse -Directory |
300 Where-Object { -not $_.LinkType } |
301 Sort-Object { $_.FullName.Length } -Descending |
302 Where-Object { @(Get-ChildItem -LiteralPath $_.FullName).Count -eq 0 } |
303 ForEach-Object {
304 Remove-Item -LiteralPath $_.FullName -Force -ErrorAction Stop
305 Write-Verbose "Removed empty directory: $($_.FullName)"
306 }
307 }
308 }
309
310 $itemCount = $filteredCollection.items.Count
311 $totalAgents += $result.AgentCount
312 $totalCommands += $result.CommandCount
313 $totalInstructions += $result.InstructionCount
314 $totalSkills += $result.SkillCount
315 $generated++
316
317 Write-Host " $id ($itemCount items)" -ForegroundColor Green
318 }
319
320 # Generate marketplace.json from all collections
321 Write-MarketplaceManifest `
322 -RepoRoot $RepoRoot `
323 -Collections $allCollections `
324 -DryRun:$DryRun
325
326 # Fix git index modes for text stubs on non-symlink systems so Linux
327 # checkouts materialize real symbolic links instead of plain files.
328 if (-not $symlinkCapable) {
329 $fixedCount = Repair-PluginSymlinkIndex -PluginsDir $pluginsDir -RepoRoot $RepoRoot -DryRun:$DryRun
330 if ($fixedCount -gt 0) {
331 Write-Host " Symlink index: $fixedCount entries fixed (100644 -> 120000)" -ForegroundColor Green
332 }
333 }
334
335 Write-Host "`n--- Summary ---" -ForegroundColor Cyan
336 Write-Host " Plugins generated: $generated"
337 Write-Host " Agents: $totalAgents"
338 Write-Host " Commands: $totalCommands"
339 Write-Host " Instructions: $totalInstructions"
340 Write-Host " Skills: $totalSkills"
341
342 return New-GenerateResult -Success $true -PluginCount $generated
343}
344
345#endregion Orchestration
346
347#region Main Execution
348
349function Start-PluginGeneration {
350 <#
351 .SYNOPSIS
352 Entry point for CLI invocation. Returns 0 on success, 1 on failure.
353
354 .PARAMETER ScriptPath
355 Absolute path to this script file, used to resolve the repo root.
356
357 .PARAMETER CollectionIds
358 Optional collection IDs forwarded to Invoke-PluginGeneration.
359
360 .PARAMETER Refresh
361 Forwarded refresh switch.
362
363 .PARAMETER DryRun
364 Forwarded dry-run switch.
365
366 .PARAMETER Channel
367 Forwarded channel parameter.
368
369 .OUTPUTS
370 [int] Exit code: 0 for success, 1 for failure.
371 #>
372 [CmdletBinding()]
373 [OutputType([int])]
374 param(
375 [Parameter(Mandatory = $true)]
376 [string]$ScriptPath,
377
378 [Parameter(Mandatory = $false)]
379 [string[]]$CollectionIds,
380
381 [Parameter(Mandatory = $false)]
382 [switch]$Refresh,
383
384 [Parameter(Mandatory = $false)]
385 [switch]$DryRun,
386
387 [Parameter(Mandatory = $false)]
388 [ValidateSet('Stable', 'PreRelease')]
389 [string]$Channel = 'PreRelease'
390 )
391
392 try {
393 # Verify PowerShell-Yaml module
394 if (-not (Get-Module -ListAvailable -Name PowerShell-Yaml)) {
395 throw "Required module 'PowerShell-Yaml' is not installed."
396 }
397 Import-Module PowerShell-Yaml -ErrorAction Stop
398
399 # Resolve paths
400 $ScriptDir = Split-Path -Parent $ScriptPath
401 $RepoRoot = (Get-Item "$ScriptDir/../..").FullName
402
403 Write-Host 'HVE Core Plugin Generator' -ForegroundColor Cyan
404 Write-Host '==========================' -ForegroundColor Cyan
405
406 # Default to all + refresh when no args
407 $effectiveRefresh = $Refresh
408 if (-not $CollectionIds -and -not $Refresh.IsPresent -and -not $DryRun.IsPresent) {
409 $effectiveRefresh = [switch]::new($true)
410 }
411
412 $result = Invoke-PluginGeneration `
413 -RepoRoot $RepoRoot `
414 -CollectionIds $CollectionIds `
415 -Refresh:$effectiveRefresh `
416 -DryRun:$DryRun `
417 -Channel $Channel
418
419 if (-not $result.Success) {
420 throw $result.ErrorMessage
421 }
422
423 Write-Host ''
424 Write-Host 'Done!' -ForegroundColor Green
425 Write-Host " $($result.PluginCount) plugin(s) generated."
426
427 return 0
428 }
429 catch {
430 Write-Error "Plugin generation failed: $($_.Exception.Message)"
431 Write-CIAnnotation -Message $_.Exception.Message -Level Error
432 return 1
433 }
434}
435
436if ($MyInvocation.InvocationName -ne '.') {
437 exit (Start-PluginGeneration `
438 -ScriptPath $MyInvocation.MyCommand.Path `
439 -CollectionIds $CollectionIds `
440 -Refresh:$Refresh `
441 -DryRun:$DryRun `
442 -Channel $Channel)
443}
444#endregion
445