microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
feat/networking-agent

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/plugins/Generate-Plugins.ps1

512lines · 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 #region Update collection.md artifact tables
311 if (-not $DryRun) {
312 $collectionMdPath = Join-Path $collectionsDir "$id.collection.md"
313 if (Test-Path $collectionMdPath) {
314 $bodyContent = Get-Content -Path $collectionMdPath -Raw
315 $parsed = Split-CollectionMdByMarkers -Content $bodyContent
316
317 if ($parsed.HasMarkers) {
318 $agents = @()
319 $prompts = @()
320 $instructions = @()
321 $skills = @()
322
323 foreach ($item in $filteredCollection.items) {
324 if (-not $item.ContainsKey('kind') -or -not $item.ContainsKey('path')) {
325 continue
326 }
327 $kind = [string]$item.kind
328 $path = [string]$item.path
329 $artifactName = Get-CollectionArtifactKey -Kind $kind -Path $path
330
331 $resolvedPath = Join-Path $RepoRoot ($path -replace '^\./', '')
332 if ($kind -eq 'skill') {
333 $resolvedPath = Join-Path $resolvedPath 'SKILL.md'
334 }
335 $artifactDesc = Get-ArtifactDescription -FilePath $resolvedPath
336
337 $entry = @{ Name = $artifactName; Description = $artifactDesc }
338 switch ($kind) {
339 'agent' { $agents += $entry }
340 'prompt' { $prompts += $entry }
341 'instruction' { $instructions += $entry }
342 'skill' { $skills += $entry }
343 }
344 }
345
346 $artifactSections = [System.Text.StringBuilder]::new()
347
348 foreach ($section in @(
349 @{ Title = 'Chat Agents'; Items = $agents },
350 @{ Title = 'Prompts'; Items = $prompts },
351 @{ Title = 'Instructions'; Items = $instructions },
352 @{ Title = 'Skills'; Items = $skills }
353 )) {
354 if ($section.Items.Count -eq 0) { continue }
355
356 $null = $artifactSections.AppendLine("### $($section.Title)")
357 $null = $artifactSections.AppendLine()
358 $null = $artifactSections.AppendLine('| Name | Description |')
359 $null = $artifactSections.AppendLine('|------|-------------|')
360 foreach ($entry in ($section.Items | Sort-Object { $_.Name })) {
361 $null = $artifactSections.AppendLine("| **$($entry.Name)** | $($entry.Description) |")
362 }
363 $null = $artifactSections.AppendLine()
364 }
365
366 $generatedBlock = $artifactSections.ToString().TrimEnd()
367 $updatedCollectionMd = "$($parsed.Intro)`n`n$($CollectionMdBeginMarker)`n`n$generatedBlock`n`n$($CollectionMdEndMarker)"
368 if (-not [string]::IsNullOrWhiteSpace($parsed.Footer)) {
369 $updatedCollectionMd += "`n`n$($parsed.Footer.TrimEnd())"
370 }
371 $updatedCollectionMd += "`n"
372 Set-ContentIfChanged -Path $collectionMdPath -Value $updatedCollectionMd
373 }
374 }
375 }
376 #endregion
377
378 $itemCount = $filteredCollection.items.Count
379 $totalAgents += $result.AgentCount
380 $totalCommands += $result.CommandCount
381 $totalInstructions += $result.InstructionCount
382 $totalSkills += $result.SkillCount
383 $generated++
384
385 Write-Host " $id ($itemCount items)" -ForegroundColor Green
386 }
387
388 # Generate marketplace.json from all collections
389 Write-MarketplaceManifest `
390 -RepoRoot $RepoRoot `
391 -Collections $allCollections `
392 -DryRun:$DryRun
393
394 # Fix git index modes for text stubs on non-symlink systems so Linux
395 # checkouts materialize real symbolic links instead of plain files.
396 if (-not $symlinkCapable) {
397 $fixedCount = Repair-PluginSymlinkIndex -PluginsDir $pluginsDir -RepoRoot $RepoRoot -DryRun:$DryRun
398 if ($fixedCount -gt 0) {
399 Write-Host " Symlink index: $fixedCount entries fixed (100644 -> 120000)" -ForegroundColor Green
400 }
401 }
402
403 Write-Host "`n--- Summary ---" -ForegroundColor Cyan
404 Write-Host " Plugins generated: $generated"
405 Write-Host " Agents: $totalAgents"
406 Write-Host " Commands: $totalCommands"
407 Write-Host " Instructions: $totalInstructions"
408 Write-Host " Skills: $totalSkills"
409
410 return New-GenerateResult -Success $true -PluginCount $generated
411}
412
413#endregion Orchestration
414
415#region Main Execution
416
417function Start-PluginGeneration {
418 <#
419 .SYNOPSIS
420 Entry point for CLI invocation. Returns 0 on success, 1 on failure.
421
422 .PARAMETER ScriptPath
423 Absolute path to this script file, used to resolve the repo root.
424
425 .PARAMETER CollectionIds
426 Optional collection IDs forwarded to Invoke-PluginGeneration.
427
428 .PARAMETER Refresh
429 Forwarded refresh switch.
430
431 .PARAMETER DryRun
432 Forwarded dry-run switch.
433
434 .PARAMETER Channel
435 Forwarded channel parameter.
436
437 .OUTPUTS
438 [int] Exit code: 0 for success, 1 for failure.
439 #>
440 [CmdletBinding()]
441 [OutputType([int])]
442 param(
443 [Parameter(Mandatory = $true)]
444 [string]$ScriptPath,
445
446 [Parameter(Mandatory = $false)]
447 [string[]]$CollectionIds,
448
449 [Parameter(Mandatory = $false)]
450 [switch]$Refresh,
451
452 [Parameter(Mandatory = $false)]
453 [switch]$DryRun,
454
455 [Parameter(Mandatory = $false)]
456 [ValidateSet('Stable', 'PreRelease')]
457 [string]$Channel = 'PreRelease'
458 )
459
460 try {
461 # Verify PowerShell-Yaml module
462 if (-not (Get-Module -ListAvailable -Name PowerShell-Yaml)) {
463 throw "Required module 'PowerShell-Yaml' is not installed."
464 }
465 Import-Module PowerShell-Yaml -ErrorAction Stop
466
467 # Resolve paths
468 $ScriptDir = Split-Path -Parent $ScriptPath
469 $RepoRoot = (Get-Item "$ScriptDir/../..").FullName
470
471 Write-Host 'HVE Core Plugin Generator' -ForegroundColor Cyan
472 Write-Host '==========================' -ForegroundColor Cyan
473
474 # Default to all + refresh when no args
475 $effectiveRefresh = $Refresh
476 if (-not $CollectionIds -and -not $Refresh.IsPresent -and -not $DryRun.IsPresent) {
477 $effectiveRefresh = [switch]::new($true)
478 }
479
480 $result = Invoke-PluginGeneration `
481 -RepoRoot $RepoRoot `
482 -CollectionIds $CollectionIds `
483 -Refresh:$effectiveRefresh `
484 -DryRun:$DryRun `
485 -Channel $Channel
486
487 if (-not $result.Success) {
488 throw $result.ErrorMessage
489 }
490
491 Write-Host ''
492 Write-Host 'Done!' -ForegroundColor Green
493 Write-Host " $($result.PluginCount) plugin(s) generated."
494
495 return 0
496 }
497 catch {
498 Write-Error "Plugin generation failed: $($_.Exception.Message)"
499 Write-CIAnnotation -Message $_.Exception.Message -Level Error
500 return 1
501 }
502}
503
504if ($MyInvocation.InvocationName -ne '.') {
505 exit (Start-PluginGeneration `
506 -ScriptPath $MyInvocation.MyCommand.Path `
507 -CollectionIds $CollectionIds `
508 -Refresh:$Refresh `
509 -DryRun:$DryRun `
510 -Channel $Channel)
511}
512#endregion
513