microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
fix/1585-msdate-fresh

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/plugins/Generate-Plugins.ps1

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