microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
feat/1873-devcontainer

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/plugins/Generate-Plugins.ps1

525lines · 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 and removed are 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 ($effectiveMaturity -eq 'removed') {
133 Write-Verbose "Skipping removed item: $($item.path)"
134 continue
135 }
136 if ($allowedMaturities -contains $effectiveMaturity) {
137 $filteredItems += $item
138 }
139 }
140
141 $filteredCollection = @{}
142 foreach ($key in $Collection.Keys) {
143 $filteredCollection[$key] = $Collection[$key]
144 }
145 $filteredCollection['items'] = $filteredItems
146
147 return $filteredCollection
148}
149
150function Invoke-PluginGeneration {
151 <#
152 .SYNOPSIS
153 Orchestrates plugin directory generation from collection manifests.
154
155 .DESCRIPTION
156 Loads collection manifests from the collections/ directory, optionally
157 filters to specified IDs, and generates plugin directory structures
158 under plugins/. Each plugin receives symlinks to source artifacts,
159 a plugin.json manifest, and an auto-generated README.
160
161 .PARAMETER RepoRoot
162 Absolute path to the repository root directory.
163
164 .PARAMETER CollectionIds
165 Optional. Array of collection IDs to generate. Generates all when omitted.
166
167 .PARAMETER Refresh
168 When specified, removes existing plugin directories before regenerating.
169
170 .PARAMETER DryRun
171 When specified, logs actions without creating files or directories.
172
173 .PARAMETER Channel
174 Release channel controlling item maturity eligibility.
175
176 .OUTPUTS
177 Hashtable with Success, PluginCount, and ErrorMessage keys
178 via New-GenerateResult.
179 #>
180 [CmdletBinding()]
181 [OutputType([hashtable])]
182 param(
183 [Parameter(Mandatory = $true)]
184 [ValidateNotNullOrEmpty()]
185 [string]$RepoRoot,
186
187 [Parameter(Mandatory = $false)]
188 [string[]]$CollectionIds,
189
190 [Parameter(Mandatory = $false)]
191 [switch]$Refresh,
192
193 [Parameter(Mandatory = $false)]
194 [switch]$DryRun,
195
196 [Parameter(Mandatory = $false)]
197 [ValidateSet('Stable', 'PreRelease')]
198 [string]$Channel = 'PreRelease'
199 )
200
201 $collectionsDir = Join-Path -Path $RepoRoot -ChildPath 'collections'
202 $pluginsDir = Join-Path -Path $RepoRoot -ChildPath 'plugins'
203
204 # Read repo version from package.json for plugin manifests
205 $packageJsonPath = Join-Path -Path $RepoRoot -ChildPath 'package.json'
206 $repoVersion = (Get-Content -Path $packageJsonPath -Raw | ConvertFrom-Json).version
207
208 # Auto-update hve-core-all collection with discovered artifacts
209 $updateResult = Update-HveCoreAllCollection -RepoRoot $RepoRoot -DryRun:$DryRun
210 Write-Verbose "hve-core-all updated: $($updateResult.ItemCount) items ($($updateResult.AddedCount) added, $($updateResult.RemovedCount) removed)"
211
212 # Probe symlink capability once for the entire generation run
213 $symlinkCapable = Test-SymlinkCapability
214 Write-Verbose "Symlink capability: $symlinkCapable ($(if ($symlinkCapable) { 'using symlinks' } else { 'using file copies' }))"
215
216 # Load all collection manifests
217 $allCollections = Get-AllCollections -CollectionsDir $collectionsDir
218
219 if ($allCollections.Count -eq 0) {
220 Write-Warning 'No collection manifests found in collections/'
221 return New-GenerateResult -Success $true -PluginCount 0
222 }
223
224 # Filter to requested IDs when provided
225 if ($CollectionIds -and $CollectionIds.Count -gt 0) {
226 $filtered = @($allCollections | Where-Object { $CollectionIds -contains $_.id })
227 $missing = @($CollectionIds | Where-Object { $_ -notin ($allCollections | ForEach-Object { $_.id }) })
228 if ($missing.Count -gt 0) {
229 Write-Warning "Collections not found: $($missing -join ', ')"
230 }
231 $allCollections = $filtered
232 }
233
234 Write-Host "`n=== Plugin Generation ===" -ForegroundColor Cyan
235 Write-Host "Collections: $($allCollections.Count)"
236 Write-Host "Channel: $Channel"
237 Write-Host "Plugins dir: $pluginsDir"
238 if ($DryRun) {
239 Write-Host '[DRY RUN] No changes will be made' -ForegroundColor Yellow
240 }
241
242 $generated = 0
243 $totalAgents = 0
244 $totalCommands = 0
245 $totalInstructions = 0
246 $totalSkills = 0
247
248 foreach ($collection in $allCollections) {
249 $id = $collection.id
250 $pluginDir = Join-Path -Path $pluginsDir -ChildPath $id
251
252 # Skip deprecated collections
253 $collectionMaturity = if ($collection.ContainsKey('maturity') -and $collection.maturity) {
254 [string]$collection.maturity
255 } else { 'stable' }
256
257 if ($collectionMaturity -eq 'deprecated') {
258 Write-Verbose "Skipping deprecated collection: $id"
259 continue
260 }
261
262 if ($collectionMaturity -eq 'removed') {
263 Write-Verbose "Skipping removed collection: $id"
264 continue
265 }
266
267 # Generate plugin directory structure (overwrites in place)
268 $filteredCollection = Select-CollectionItemsByChannel -Collection $collection -Channel $Channel
269
270 # Refresh collection.md before generating the plugin README so the
271 # embedded Overview block uses current artifact descriptions.
272 if (-not $DryRun) {
273 $collectionMdPath = Join-Path $collectionsDir "$id.collection.md"
274 if (Test-Path $collectionMdPath) {
275 $bodyContent = Get-Content -Path $collectionMdPath -Raw
276 $parsed = Split-CollectionMdByMarkers -Content $bodyContent
277
278 if ($parsed.HasMarkers) {
279 $agents = @()
280 $prompts = @()
281 $instructions = @()
282 $skills = @()
283
284 foreach ($item in $filteredCollection.items) {
285 if (-not $item.ContainsKey('kind') -or -not $item.ContainsKey('path')) {
286 continue
287 }
288 $kind = [string]$item.kind
289 $path = [string]$item.path
290 $artifactName = Get-CollectionArtifactKey -Kind $kind -Path $path
291
292 $resolvedPath = Join-Path $RepoRoot ($path -replace '^\./', '')
293 if ($kind -eq 'skill') {
294 $resolvedPath = Join-Path $resolvedPath 'SKILL.md'
295 }
296 $artifactDesc = Get-ArtifactDescription -FilePath $resolvedPath
297
298 $entry = @{ Name = $artifactName; Description = $artifactDesc }
299 switch ($kind) {
300 'agent' { $agents += $entry }
301 'prompt' { $prompts += $entry }
302 'instruction' { $instructions += $entry }
303 'skill' { $skills += $entry }
304 }
305 }
306
307 $artifactSections = [System.Text.StringBuilder]::new()
308
309 foreach ($section in @(
310 @{ Title = 'Chat Agents'; Items = $agents },
311 @{ Title = 'Prompts'; Items = $prompts },
312 @{ Title = 'Instructions'; Items = $instructions },
313 @{ Title = 'Skills'; Items = $skills }
314 )) {
315 if ($section.Items.Count -eq 0) { continue }
316
317 $null = $artifactSections.AppendLine("### $($section.Title)")
318 $null = $artifactSections.AppendLine()
319 $null = $artifactSections.AppendLine('| Name | Description |')
320 $null = $artifactSections.AppendLine('|------|-------------|')
321 foreach ($entry in ($section.Items | Sort-Object { $_.Name })) {
322 $null = $artifactSections.AppendLine("| **$($entry.Name)** | $($entry.Description) |")
323 }
324 $null = $artifactSections.AppendLine()
325 }
326
327 $generatedBlock = $artifactSections.ToString().TrimEnd()
328 $intro = $parsed.Intro.TrimEnd()
329 if ($intro -notmatch '(?m)^## Included Artifacts\s*$') {
330 $intro = "$intro`n`n## Included Artifacts"
331 }
332 $updatedCollectionMd = "$intro`n`n$($CollectionMdBeginMarker)`n`n$generatedBlock`n`n$($CollectionMdEndMarker)"
333 if (-not [string]::IsNullOrWhiteSpace($parsed.Footer)) {
334 $updatedCollectionMd += "`n`n$($parsed.Footer.TrimEnd())"
335 }
336 $updatedCollectionMd += "`n"
337 Set-ContentIfChanged -Path $collectionMdPath -Value $updatedCollectionMd
338 }
339 }
340 }
341
342 $result = Write-PluginDirectory -Collection $filteredCollection `
343 -PluginsDir $pluginsDir `
344 -RepoRoot $RepoRoot `
345 -Version $repoVersion `
346 -Maturity $collectionMaturity `
347 -DryRun:$DryRun `
348 -SymlinkCapable:$symlinkCapable
349
350 # Orphan cleanup in Refresh mode
351 if ($Refresh -and (Test-Path -LiteralPath $pluginDir)) {
352 $generatedFiles = $result.GeneratedFiles
353 $existingFiles = [System.Collections.Generic.List[string]]::new()
354 $scanQueue = [System.Collections.Generic.Queue[string]]::new()
355 $scanQueue.Enqueue($pluginDir)
356 while ($scanQueue.Count -gt 0) {
357 $currentDir = $scanQueue.Dequeue()
358 foreach ($entry in Get-ChildItem -LiteralPath $currentDir -Force) {
359 if ($entry.PSIsContainer -and -not $entry.LinkType) {
360 $scanQueue.Enqueue($entry.FullName)
361 }
362 else {
363 $existingFiles.Add($entry.FullName)
364 }
365 }
366 }
367 foreach ($existingFile in $existingFiles) {
368 if (-not $generatedFiles.Contains($existingFile)) {
369 if ($DryRun) {
370 Write-Host " [DRY RUN] Would remove orphan: $existingFile" -ForegroundColor Yellow
371 }
372 else {
373 Remove-Item -LiteralPath $existingFile -Force -ErrorAction Stop
374 Write-Verbose "Removed orphan file: $existingFile"
375 }
376 }
377 }
378 # Remove empty directories bottom-up
379 if (-not $DryRun) {
380 Get-ChildItem -LiteralPath $pluginDir -Recurse -Directory |
381 Where-Object { -not $_.LinkType } |
382 Sort-Object { $_.FullName.Length } -Descending |
383 Where-Object { @(Get-ChildItem -LiteralPath $_.FullName).Count -eq 0 } |
384 ForEach-Object {
385 Remove-Item -LiteralPath $_.FullName -Force -ErrorAction Stop
386 Write-Verbose "Removed empty directory: $($_.FullName)"
387 }
388 }
389 }
390
391 $itemCount = $filteredCollection.items.Count
392 $totalAgents += $result.AgentCount
393 $totalCommands += $result.CommandCount
394 $totalInstructions += $result.InstructionCount
395 $totalSkills += $result.SkillCount
396 $generated++
397
398 Write-Host " $id ($itemCount items)" -ForegroundColor Green
399 }
400
401 # Generate marketplace.json from all collections
402 Write-MarketplaceManifest `
403 -RepoRoot $RepoRoot `
404 -Collections $allCollections `
405 -DryRun:$DryRun
406
407 # Fix git index modes for text stubs on non-symlink systems so Linux
408 # checkouts materialize real symbolic links instead of plain files.
409 if (-not $symlinkCapable) {
410 $fixedCount = Repair-PluginSymlinkIndex -PluginsDir $pluginsDir -RepoRoot $RepoRoot -DryRun:$DryRun
411 if ($fixedCount -gt 0) {
412 Write-Host " Symlink index: $fixedCount entries fixed (100644 -> 120000)" -ForegroundColor Green
413 }
414 }
415
416 Write-Host "`n--- Summary ---" -ForegroundColor Cyan
417 Write-Host " Plugins generated: $generated"
418 Write-Host " Agents: $totalAgents"
419 Write-Host " Commands: $totalCommands"
420 Write-Host " Instructions: $totalInstructions"
421 Write-Host " Skills: $totalSkills"
422
423 return New-GenerateResult -Success $true -PluginCount $generated
424}
425
426#endregion Orchestration
427
428#region Main Execution
429
430function Start-PluginGeneration {
431 <#
432 .SYNOPSIS
433 Entry point for CLI invocation. Returns 0 on success, 1 on failure.
434
435 .PARAMETER ScriptPath
436 Absolute path to this script file, used to resolve the repo root.
437
438 .PARAMETER CollectionIds
439 Optional collection IDs forwarded to Invoke-PluginGeneration.
440
441 .PARAMETER Refresh
442 Forwarded refresh switch.
443
444 .PARAMETER DryRun
445 Forwarded dry-run switch.
446
447 .PARAMETER Channel
448 Forwarded channel parameter.
449
450 .OUTPUTS
451 [int] Exit code: 0 for success, 1 for failure.
452 #>
453 [CmdletBinding()]
454 [OutputType([int])]
455 param(
456 [Parameter(Mandatory = $true)]
457 [string]$ScriptPath,
458
459 [Parameter(Mandatory = $false)]
460 [string[]]$CollectionIds,
461
462 [Parameter(Mandatory = $false)]
463 [switch]$Refresh,
464
465 [Parameter(Mandatory = $false)]
466 [switch]$DryRun,
467
468 [Parameter(Mandatory = $false)]
469 [ValidateSet('Stable', 'PreRelease')]
470 [string]$Channel = 'PreRelease'
471 )
472
473 try {
474 # Verify PowerShell-Yaml module
475 if (-not (Get-Module -ListAvailable -Name PowerShell-Yaml)) {
476 throw "Required module 'PowerShell-Yaml' is not installed."
477 }
478 Import-Module PowerShell-Yaml -ErrorAction Stop
479
480 # Resolve paths
481 $ScriptDir = Split-Path -Parent $ScriptPath
482 $RepoRoot = (Get-Item "$ScriptDir/../..").FullName
483
484 Write-Host 'HVE Core Plugin Generator' -ForegroundColor Cyan
485 Write-Host '==========================' -ForegroundColor Cyan
486
487 # Default to all + refresh when no args
488 $effectiveRefresh = $Refresh
489 if (-not $CollectionIds -and -not $Refresh.IsPresent -and -not $DryRun.IsPresent) {
490 $effectiveRefresh = [switch]::new($true)
491 }
492
493 $result = Invoke-PluginGeneration `
494 -RepoRoot $RepoRoot `
495 -CollectionIds $CollectionIds `
496 -Refresh:$effectiveRefresh `
497 -DryRun:$DryRun `
498 -Channel $Channel
499
500 if (-not $result.Success) {
501 throw $result.ErrorMessage
502 }
503
504 Write-Host ''
505 Write-Host 'Done!' -ForegroundColor Green
506 Write-Host " $($result.PluginCount) plugin(s) generated."
507
508 return 0
509 }
510 catch {
511 Write-Error "Plugin generation failed: $($_.Exception.Message)"
512 Write-CIAnnotation -Message $_.Exception.Message -Level Error
513 return 1
514 }
515}
516
517if ($MyInvocation.InvocationName -ne '.') {
518 exit (Start-PluginGeneration `
519 -ScriptPath $MyInvocation.MyCommand.Path `
520 -CollectionIds $CollectionIds `
521 -Refresh:$Refresh `
522 -DryRun:$DryRun `
523 -Channel $Channel)
524}
525#endregion
526