microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
feat/1637-d-skill-paths

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 # 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 $updatedCollectionMd = "$($parsed.Intro)`n`n$($CollectionMdBeginMarker)`n`n$generatedBlock`n`n$($CollectionMdEndMarker)"
329 if (-not [string]::IsNullOrWhiteSpace($parsed.Footer)) {
330 $updatedCollectionMd += "`n`n$($parsed.Footer.TrimEnd())"
331 }
332 $updatedCollectionMd += "`n"
333 Set-ContentIfChanged -Path $collectionMdPath -Value $updatedCollectionMd
334 }
335 }
336 }
337
338 $result = Write-PluginDirectory -Collection $filteredCollection `
339 -PluginsDir $pluginsDir `
340 -RepoRoot $RepoRoot `
341 -Version $repoVersion `
342 -Maturity $collectionMaturity `
343 -DryRun:$DryRun `
344 -SymlinkCapable:$symlinkCapable
345
346 # Orphan cleanup in Refresh mode
347 if ($Refresh -and (Test-Path -LiteralPath $pluginDir)) {
348 $generatedFiles = $result.GeneratedFiles
349 $existingFiles = [System.Collections.Generic.List[string]]::new()
350 $scanQueue = [System.Collections.Generic.Queue[string]]::new()
351 $scanQueue.Enqueue($pluginDir)
352 while ($scanQueue.Count -gt 0) {
353 $currentDir = $scanQueue.Dequeue()
354 foreach ($entry in Get-ChildItem -LiteralPath $currentDir -Force) {
355 if ($entry.PSIsContainer -and -not $entry.LinkType) {
356 $scanQueue.Enqueue($entry.FullName)
357 }
358 else {
359 $existingFiles.Add($entry.FullName)
360 }
361 }
362 }
363 foreach ($existingFile in $existingFiles) {
364 if (-not $generatedFiles.Contains($existingFile)) {
365 if ($DryRun) {
366 Write-Host " [DRY RUN] Would remove orphan: $existingFile" -ForegroundColor Yellow
367 }
368 else {
369 Remove-Item -LiteralPath $existingFile -Force -ErrorAction Stop
370 Write-Verbose "Removed orphan file: $existingFile"
371 }
372 }
373 }
374 # Remove empty directories bottom-up
375 if (-not $DryRun) {
376 Get-ChildItem -LiteralPath $pluginDir -Recurse -Directory |
377 Where-Object { -not $_.LinkType } |
378 Sort-Object { $_.FullName.Length } -Descending |
379 Where-Object { @(Get-ChildItem -LiteralPath $_.FullName).Count -eq 0 } |
380 ForEach-Object {
381 Remove-Item -LiteralPath $_.FullName -Force -ErrorAction Stop
382 Write-Verbose "Removed empty directory: $($_.FullName)"
383 }
384 }
385 }
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