microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
copilot/fix-hardcoded-paths-in-artifacts

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/extension/Prepare-Extension.ps1

1840lines · 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 Prepares the HVE Core VS Code extension for packaging.
9
10.DESCRIPTION
11 This script prepares the VS Code extension by:
12 - Auto-discovering chat agents, prompts, and instruction files
13 - Filtering agents by maturity level based on channel
14 - Updating package.json with discovered components
15 - Updating changelog if provided
16
17 The package.json version is not modified.
18
19.PARAMETER ChangelogPath
20 Optional. Path to a changelog file to include in the package.
21
22.PARAMETER Channel
23 Optional. Release channel controlling which maturity levels are included.
24 'Stable' (default): Only includes agents with maturity 'stable'.
25 'PreRelease': Includes 'stable', 'preview', and 'experimental' maturity levels.
26
27.PARAMETER DryRun
28 Optional. If specified, shows what would be done without making changes.
29
30.EXAMPLE
31 ./Prepare-Extension.ps1
32 # Prepares stable channel using existing version from package.json
33
34.EXAMPLE
35 ./Prepare-Extension.ps1 -Channel PreRelease
36 # Prepares pre-release channel including experimental agents
37
38.EXAMPLE
39 ./Prepare-Extension.ps1 -ChangelogPath "./CHANGELOG.md"
40 # Prepares with changelog
41
42.NOTES
43 Dependencies: PowerShell-Yaml module
44#>
45
46[CmdletBinding()]
47param(
48 [Parameter(Mandatory = $false)]
49 [string]$ChangelogPath = "",
50
51 [Parameter(Mandatory = $false)]
52 [ValidateSet('Stable', 'PreRelease')]
53 [string]$Channel = 'Stable',
54
55 [Parameter(Mandatory = $false)]
56 [switch]$DryRun,
57
58 [Parameter(Mandatory = $false)]
59 [string]$Collection = ""
60)
61
62$ErrorActionPreference = 'Stop'
63
64Import-Module (Join-Path $PSScriptRoot "../lib/Modules/CIHelpers.psm1") -Force
65Import-Module (Join-Path $PSScriptRoot "../plugins/Modules/PluginHelpers.psm1") -Force
66
67#region Pure Functions
68
69#region Package Generation Functions
70
71function Get-CollectionDisplayName {
72 <#
73 .SYNOPSIS
74 Resolves a display name from a collection manifest.
75 .DESCRIPTION
76 Returns the displayName field if set, derives one from the name field,
77 or falls back to a default value.
78 .PARAMETER CollectionManifest
79 Parsed collection manifest hashtable.
80 .PARAMETER DefaultValue
81 Fallback display name when the manifest provides neither displayName nor name.
82 .OUTPUTS
83 [string] Resolved display name.
84 #>
85 [CmdletBinding()]
86 [OutputType([string])]
87 param(
88 [Parameter(Mandatory = $true)]
89 [hashtable]$CollectionManifest,
90
91 [Parameter(Mandatory = $true)]
92 [string]$DefaultValue
93 )
94
95 if ($CollectionManifest.ContainsKey('displayName') -and -not [string]::IsNullOrWhiteSpace([string]$CollectionManifest.displayName)) {
96 return [string]$CollectionManifest.displayName
97 }
98
99 if ($CollectionManifest.ContainsKey('name') -and -not [string]::IsNullOrWhiteSpace([string]$CollectionManifest.name)) {
100 return "HVE Core - $($CollectionManifest.name)"
101 }
102
103 return $DefaultValue
104}
105
106function Copy-TemplateWithOverrides {
107 <#
108 .SYNOPSIS
109 Clones a template object and applies field overrides.
110 .DESCRIPTION
111 Copies all properties from Template, replacing any whose key appears in
112 Overrides. Additional override keys not in the template are appended.
113 .PARAMETER Template
114 Source PSCustomObject to clone.
115 .PARAMETER Overrides
116 Hashtable of field values to override or add.
117 .OUTPUTS
118 [pscustomobject] New object with overrides applied.
119 #>
120 [CmdletBinding()]
121 [OutputType([pscustomobject])]
122 param(
123 [Parameter(Mandatory = $true)]
124 [pscustomobject]$Template,
125
126 [Parameter(Mandatory = $true)]
127 [hashtable]$Overrides
128 )
129
130 $output = [ordered]@{}
131
132 foreach ($propertyName in $Template.PSObject.Properties.Name) {
133 if ($Overrides.ContainsKey($propertyName)) {
134 $output[$propertyName] = $Overrides[$propertyName]
135 }
136 else {
137 $output[$propertyName] = $Template.$propertyName
138 }
139 }
140
141 foreach ($propertyName in $Overrides.Keys | Sort-Object) {
142 if (-not $output.Contains($propertyName)) {
143 $output[$propertyName] = $Overrides[$propertyName]
144 }
145 }
146
147 return [pscustomobject]$output
148}
149
150function Set-JsonFile {
151 <#
152 .SYNOPSIS
153 Writes an object to a JSON file with UTF-8 encoding.
154 .DESCRIPTION
155 Serializes Content to JSON and writes to Path, creating parent
156 directories as needed.
157 .PARAMETER Path
158 Destination file path.
159 .PARAMETER Content
160 Object to serialize.
161 #>
162 [CmdletBinding()]
163 param(
164 [Parameter(Mandatory = $true)]
165 [string]$Path,
166
167 [Parameter(Mandatory = $true)]
168 [object]$Content
169 )
170
171 $parent = Split-Path -Path $Path -Parent
172 if (-not (Test-Path -Path $parent)) {
173 New-Item -Path $parent -ItemType Directory -Force | Out-Null
174 }
175
176 $json = $Content | ConvertTo-Json -Depth 30
177 Set-Content -Path $Path -Value $json -Encoding utf8NoBOM
178}
179
180function Remove-StaleGeneratedFiles {
181 <#
182 .SYNOPSIS
183 Removes generated collection package files that are no longer expected.
184 .DESCRIPTION
185 Scans extension/ for package.*.json files and removes any not in the
186 expected set, keeping the directory clean of orphaned collection templates.
187 .PARAMETER RepoRoot
188 Repository root path.
189 .PARAMETER ExpectedFiles
190 Array of absolute paths that should be retained.
191 #>
192 [CmdletBinding()]
193 param(
194 [Parameter(Mandatory = $true)]
195 [string]$RepoRoot,
196
197 [Parameter(Mandatory = $true)]
198 [AllowEmptyCollection()]
199 [string[]]$ExpectedFiles
200 )
201
202 $expected = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
203 foreach ($file in $ExpectedFiles) {
204 $null = $expected.Add([System.IO.Path]::GetFullPath($file))
205 }
206
207 $extensionDir = Join-Path $RepoRoot 'extension'
208 Get-ChildItem -Path $extensionDir -Filter 'package.*.json' -File | ForEach-Object {
209 $fullPath = [System.IO.Path]::GetFullPath($_.FullName)
210 if (-not $expected.Contains($fullPath)) {
211 Remove-Item -Path $_.FullName -Force
212 }
213 }
214}
215
216function Invoke-ExtensionCollectionsGeneration {
217 <#
218 .SYNOPSIS
219 Generates collection package files from root collection manifests.
220 .DESCRIPTION
221 Reads the package template and each collections/*.collection.yml file,
222 producing extension/package.json (for hve-core) and
223 extension/package.{id}.json for every other collection. Stale collection
224 files are removed.
225 .PARAMETER RepoRoot
226 Repository root path containing collections/ and extension/templates/.
227 .OUTPUTS
228 [string[]] Array of generated file paths.
229 #>
230 [CmdletBinding()]
231 [OutputType([string[]])]
232 param(
233 [Parameter(Mandatory = $true)]
234 [string]$RepoRoot
235 )
236
237 $collectionsDir = Join-Path $RepoRoot 'collections'
238 $templatesDir = Join-Path $RepoRoot 'extension/templates'
239
240 $packageTemplatePath = Join-Path $templatesDir 'package.template.json'
241
242 if (-not (Test-Path $packageTemplatePath)) {
243 throw "Package template not found: $packageTemplatePath"
244 }
245
246 if (-not (Get-Module -ListAvailable -Name PowerShell-Yaml)) {
247 throw "Required module 'PowerShell-Yaml' is not installed."
248 }
249
250 Import-Module PowerShell-Yaml -ErrorAction Stop
251
252 $packageTemplate = Get-Content -Path $packageTemplatePath -Raw | ConvertFrom-Json
253
254 $collectionFiles = Get-ChildItem -Path $collectionsDir -Filter '*.collection.yml' -File | Sort-Object Name
255 if ($collectionFiles.Count -eq 0) {
256 throw "No root collection files found in $collectionsDir"
257 }
258
259 $expectedFiles = @()
260
261 foreach ($collectionFile in $collectionFiles) {
262 $collection = Get-CollectionManifest -CollectionPath $collectionFile.FullName
263 if ($collection -isnot [hashtable]) {
264 throw "Collection manifest must be a hashtable: $($collectionFile.FullName)"
265 }
266
267 $collectionId = [string]$collection.id
268 if ([string]::IsNullOrWhiteSpace($collectionId)) {
269 throw "Collection id is required: $($collectionFile.FullName)"
270 }
271
272 $collectionDescription = if ($collection.ContainsKey('description')) { [string]$collection.description } else { [string]$packageTemplate.description }
273
274 $extensionName = switch ($collectionId) {
275 'hve-core' { [string]$packageTemplate.name }
276 'hve-core-all' { 'hve-core-all' }
277 default { "hve-$collectionId" }
278 }
279 $extensionDisplayName = switch ($collectionId) {
280 'hve-core' { [string]$packageTemplate.displayName }
281 'hve-core-all' { 'HVE Core - All' }
282 default { Get-CollectionDisplayName -CollectionManifest $collection -DefaultValue ([string]$packageTemplate.displayName) }
283 }
284
285 $packageTemplateOutput = Copy-TemplateWithOverrides -Template $packageTemplate -Overrides @{
286 name = $extensionName
287 displayName = $extensionDisplayName
288 description = $collectionDescription
289 }
290
291 $packagePath = switch ($collectionId) {
292 'hve-core' { Join-Path $RepoRoot 'extension/package.json' }
293 'hve-core-all' { Join-Path $RepoRoot 'extension/package.hve-core-all.json' }
294 default { Join-Path $RepoRoot "extension/package.$collectionId.json" }
295 }
296
297 Set-JsonFile -Path $packagePath -Content $packageTemplateOutput
298 $expectedFiles += $packagePath
299 }
300
301 Remove-StaleGeneratedFiles -RepoRoot $RepoRoot -ExpectedFiles $expectedFiles
302
303 # Generate README files for each collection
304 $readmeTemplatePath = Join-Path $templatesDir 'README.template.md'
305 foreach ($collectionFile in $collectionFiles) {
306 $collection = Get-CollectionManifest -CollectionPath $collectionFile.FullName
307 $collectionId = [string]$collection.id
308
309 $collectionMdPath = Join-Path $collectionsDir "$collectionId.collection.md"
310 if (-not (Test-Path $collectionMdPath)) {
311 continue
312 }
313
314 $readmePath = switch ($collectionId) {
315 'hve-core' { Join-Path $RepoRoot 'extension/README.md' }
316 'hve-core-all' { Join-Path $RepoRoot 'extension/README.hve-core-all.md' }
317 default { Join-Path $RepoRoot "extension/README.$collectionId.md" }
318 }
319
320 New-CollectionReadme -Collection $collection -CollectionMdPath $collectionMdPath -TemplatePath $readmeTemplatePath -RepoRoot $RepoRoot -OutputPath $readmePath
321 }
322
323 return $expectedFiles
324}
325
326function Get-ArtifactDescription {
327 <#
328 .SYNOPSIS
329 Reads the description from an artifact file's YAML frontmatter.
330 .DESCRIPTION
331 Parses the YAML frontmatter block at the top of a markdown file and
332 returns the description field value. Returns an empty string when the
333 file is missing, has no frontmatter, or lacks a description field.
334 Strips the common " - Brought to you by microsoft/hve-core" suffix.
335 .PARAMETER FilePath
336 Absolute path to the artifact markdown file.
337 .OUTPUTS
338 [string] Description text, or empty string if unavailable.
339 #>
340 [CmdletBinding()]
341 [OutputType([string])]
342 param(
343 [Parameter(Mandatory = $true)]
344 [string]$FilePath
345 )
346
347 if (-not (Test-Path $FilePath)) {
348 return ''
349 }
350
351 $content = Get-Content -Path $FilePath -Raw
352 if ($content -match '(?s)^---\s*\r?\n(.*?)\r?\n---') {
353 $yamlBlock = $Matches[1]
354 try {
355 $frontmatter = ConvertFrom-Yaml -Yaml $yamlBlock
356 if ($frontmatter -is [hashtable] -and $frontmatter.ContainsKey('description')) {
357 $desc = [string]$frontmatter.description
358 # Strip the common branding suffix
359 $desc = $desc -replace '\s*-\s*Brought to you by microsoft/hve-core$', ''
360 return $desc.Trim()
361 }
362 }
363 catch {
364 Write-Verbose "Failed to parse frontmatter from $FilePath`: $_"
365 }
366 }
367
368 return ''
369}
370
371function New-CollectionReadme {
372 <#
373 .SYNOPSIS
374 Generates a README.md for an extension collection from a template.
375 .DESCRIPTION
376 Reads a README template and replaces placeholder tokens with collection
377 metadata, hand-authored body content, and auto-generated artifact tables
378 with descriptions read from each artifact's YAML frontmatter.
379 Tokens: {{DISPLAY_NAME}}, {{DESCRIPTION}}, {{BODY}}, {{ARTIFACTS}},
380 {{FULL_EDITION}}.
381 .PARAMETER Collection
382 Parsed collection manifest hashtable.
383 .PARAMETER CollectionMdPath
384 Path to the collection markdown body file.
385 .PARAMETER TemplatePath
386 Path to the README template file containing placeholder tokens.
387 .PARAMETER RepoRoot
388 Repository root path for resolving artifact file paths.
389 .PARAMETER OutputPath
390 Destination path for the generated README.
391 #>
392 [CmdletBinding()]
393 param(
394 [Parameter(Mandatory = $true)]
395 [hashtable]$Collection,
396
397 [Parameter(Mandatory = $true)]
398 [string]$CollectionMdPath,
399
400 [Parameter(Mandatory = $true)]
401 [string]$TemplatePath,
402
403 [Parameter(Mandatory = $true)]
404 [string]$RepoRoot,
405
406 [Parameter(Mandatory = $true)]
407 [string]$OutputPath
408 )
409
410 $collectionId = [string]$Collection.id
411 $displayName = switch ($collectionId) {
412 'hve-core' { 'HVE Core' }
413 'hve-core-all' { 'HVE Core - All' }
414 default { Get-CollectionDisplayName -CollectionManifest $Collection -DefaultValue "HVE Core - $collectionId" }
415 }
416 $description = if ($Collection.ContainsKey('description')) { [string]$Collection.description } else { '' }
417
418 $collectionMaturity = if ($Collection.ContainsKey('maturity') -and -not [string]::IsNullOrWhiteSpace([string]$Collection.maturity)) {
419 [string]$Collection.maturity
420 } else { 'stable' }
421
422 $maturityNotice = if ($collectionMaturity -eq 'experimental') {
423 '> **⚠️ Experimental** — This collection is experimental and available only in the Pre-Release channel. Contents may change or be removed without notice.'
424 } else { '' }
425
426 $bodyContent = (Get-Content -Path $CollectionMdPath -Raw).Trim()
427
428 # Collect artifacts with descriptions grouped by kind
429 $agents = @()
430 $prompts = @()
431 $instructions = @()
432 $skills = @()
433
434 if ($Collection.ContainsKey('items')) {
435 foreach ($item in $Collection.items) {
436 if (-not $item.ContainsKey('kind') -or -not $item.ContainsKey('path')) {
437 continue
438 }
439 $kind = [string]$item.kind
440 $path = [string]$item.path
441 $artifactName = Get-CollectionArtifactKey -Kind $kind -Path $path
442
443 # Resolve full file path for frontmatter reading
444 $resolvedPath = Join-Path $RepoRoot ($path -replace '^\./', '')
445 if ($kind -eq 'skill') {
446 $resolvedPath = Join-Path $resolvedPath 'SKILL.md'
447 }
448 $artifactDesc = Get-ArtifactDescription -FilePath $resolvedPath
449
450 $entry = @{ Name = $artifactName; Description = $artifactDesc }
451 switch ($kind) {
452 'agent' { $agents += $entry }
453 'prompt' { $prompts += $entry }
454 'instruction' { $instructions += $entry }
455 'skill' { $skills += $entry }
456 }
457 }
458 }
459
460 # Build markdown tables for each artifact kind
461 $artifactSections = [System.Text.StringBuilder]::new()
462
463 foreach ($section in @(
464 @{ Title = 'Chat Agents'; Items = $agents },
465 @{ Title = 'Prompts'; Items = $prompts },
466 @{ Title = 'Instructions'; Items = $instructions },
467 @{ Title = 'Skills'; Items = $skills }
468 )) {
469 if ($section.Items.Count -eq 0) { continue }
470
471 $null = $artifactSections.AppendLine("### $($section.Title)")
472 $null = $artifactSections.AppendLine()
473 $null = $artifactSections.AppendLine('| Name | Description |')
474 $null = $artifactSections.AppendLine('|------|-------------|')
475 foreach ($entry in ($section.Items | Sort-Object { $_.Name })) {
476 $null = $artifactSections.AppendLine("| **$($entry.Name)** | $($entry.Description) |")
477 }
478 $null = $artifactSections.AppendLine()
479 }
480
481 $fullEdition = if ($collectionId -notin @('hve-core', 'hve-core-all')) {
482 "## Full Edition`n`nLooking for more agents covering additional domains? Check out the full [HVE Core](https://marketplace.visualstudio.com/items?itemName=ise-hve-essentials.hve-core) extension."
483 }
484 else {
485 ''
486 }
487
488 # Read template and replace tokens
489 $template = Get-Content -Path $TemplatePath -Raw
490 $readmeContent = $template `
491 -replace '\{\{DISPLAY_NAME\}\}', $displayName `
492 -replace '\{\{DESCRIPTION\}\}', $description `
493 -replace '\{\{MATURITY_NOTICE\}\}', $maturityNotice `
494 -replace '\{\{BODY\}\}', $bodyContent `
495 -replace '\{\{ARTIFACTS\}\}', $artifactSections.ToString().TrimEnd() `
496 -replace '\{\{FULL_EDITION\}\}', $fullEdition
497
498 # Clean up blank lines left by empty token replacements
499 $readmeContent = $readmeContent -replace '(\r?\n){3,}', "`n`n"
500 $readmeContent = $readmeContent.TrimEnd() + "`n"
501
502 Set-Content -Path $OutputPath -Value $readmeContent -Encoding utf8NoBOM -NoNewline
503}
504
505#endregion Package Generation Functions
506
507function Get-AllowedMaturities {
508 <#
509 .SYNOPSIS
510 Returns allowed maturity levels based on release channel.
511 .DESCRIPTION
512 Pure function that determines which maturity levels (stable, preview, experimental)
513 are included in the extension package based on the specified channel.
514 .PARAMETER Channel
515 Release channel. 'Stable' returns only stable; 'PreRelease' includes all levels.
516 .OUTPUTS
517 [string[]] Array of allowed maturity level strings.
518 #>
519 [CmdletBinding()]
520 [OutputType([string[]])]
521 param(
522 [Parameter(Mandatory = $true)]
523 [ValidateSet('Stable', 'PreRelease')]
524 [string]$Channel
525 )
526
527 if ($Channel -eq 'PreRelease') {
528 return @('stable', 'preview', 'experimental')
529 }
530 return @('stable')
531}
532
533function Test-CollectionMaturityEligible {
534 <#
535 .SYNOPSIS
536 Checks whether a collection is eligible for the specified release channel.
537 .DESCRIPTION
538 Pure function that evaluates collection-level maturity against channel rules.
539 Experimental collections are eligible only for PreRelease. Deprecated collections
540 are excluded from all channels.
541 .PARAMETER CollectionManifest
542 Parsed collection manifest hashtable.
543 .PARAMETER Channel
544 Release channel ('Stable' or 'PreRelease').
545 .OUTPUTS
546 [hashtable] With IsEligible bool and Reason string.
547 #>
548 [CmdletBinding()]
549 [OutputType([hashtable])]
550 param(
551 [Parameter(Mandatory = $true)]
552 [hashtable]$CollectionManifest,
553
554 [Parameter(Mandatory = $true)]
555 [ValidateSet('Stable', 'PreRelease')]
556 [string]$Channel
557 )
558
559 $maturity = 'stable'
560 if ($CollectionManifest.ContainsKey('maturity') -and $CollectionManifest['maturity']) {
561 $maturity = $CollectionManifest['maturity']
562 }
563
564 switch ($maturity) {
565 'deprecated' {
566 return @{
567 IsEligible = $false
568 Reason = "Collection '$($CollectionManifest.id)' is deprecated and excluded from all channels"
569 }
570 }
571 'experimental' {
572 if ($Channel -eq 'Stable') {
573 return @{
574 IsEligible = $false
575 Reason = "Collection '$($CollectionManifest.id)' is experimental and excluded from Stable channel"
576 }
577 }
578 return @{ IsEligible = $true; Reason = '' }
579 }
580 'preview' {
581 return @{ IsEligible = $true; Reason = '' }
582 }
583 'stable' {
584 return @{ IsEligible = $true; Reason = '' }
585 }
586 default {
587 return @{
588 IsEligible = $false
589 Reason = "Collection '$($CollectionManifest.id)' has invalid maturity value: $maturity"
590 }
591 }
592 }
593}
594
595function Test-GlobMatch {
596 <#
597 .SYNOPSIS
598 Tests whether a name matches any of the provided glob patterns.
599 .DESCRIPTION
600 Uses PowerShell's -like operator to test glob pattern matching with
601 * (any characters) and ? (single character) wildcards.
602 .PARAMETER Name
603 The artifact name to test against patterns.
604 .PARAMETER Patterns
605 Array of glob patterns to match against.
606 .OUTPUTS
607 [bool] True if name matches any pattern, false otherwise.
608 #>
609 [CmdletBinding()]
610 [OutputType([bool])]
611 param(
612 [Parameter(Mandatory = $true)]
613 [string]$Name,
614
615 [Parameter(Mandatory = $true)]
616 [string[]]$Patterns
617 )
618
619 foreach ($pattern in $Patterns) {
620 if ($Name -like $pattern) {
621 return $true
622 }
623 }
624 return $false
625}
626
627function Get-CollectionArtifacts {
628 <#
629 .SYNOPSIS
630 Filters collection artifacts by collection item metadata and channel maturity.
631 .DESCRIPTION
632 Applies collection-level filtering to manifest items, returning artifact
633 names that match allowed maturities. Item-level maturity is used when
634 present; otherwise artifacts default to stable.
635 .PARAMETER Collection
636 Collection manifest hashtable with items.
637 .PARAMETER AllowedMaturities
638 Array of maturity levels to include.
639 .OUTPUTS
640 [hashtable] With Agents, Prompts, Instructions, Skills arrays of matching artifact names.
641 #>
642 [CmdletBinding()]
643 [OutputType([hashtable])]
644 param(
645 [Parameter(Mandatory = $true)]
646 [hashtable]$Collection,
647
648 [Parameter(Mandatory = $true)]
649 [string[]]$AllowedMaturities
650 )
651
652 $result = @{
653 Agents = @()
654 Prompts = @()
655 Instructions = @()
656 Skills = @()
657 }
658
659 if (-not $Collection.ContainsKey('items') -or @($Collection.items).Count -eq 0) {
660 return $result
661 }
662
663 foreach ($item in $Collection.items) {
664 if (-not $item.ContainsKey('kind') -or -not $item.ContainsKey('path')) {
665 continue
666 }
667
668 $kind = [string]$item.kind
669 $path = [string]$item.path
670
671 $maturity = Resolve-CollectionItemMaturity -Maturity $item.maturity
672 if ($AllowedMaturities -notcontains $maturity) {
673 continue
674 }
675
676 $artifactKey = Get-CollectionArtifactKey -Kind $kind -Path $path
677 switch ($kind) {
678 'agent' { $result.Agents += $artifactKey }
679 'prompt' { $result.Prompts += $artifactKey }
680 'instruction' { $result.Instructions += $artifactKey }
681 'skill' { $result.Skills += $artifactKey }
682 }
683 }
684
685 return $result
686}
687
688function Resolve-HandoffDependencies {
689 <#
690 .SYNOPSIS
691 Resolves transitive agent handoff dependencies using BFS traversal.
692 .DESCRIPTION
693 Starting from seed agents, performs breadth-first traversal of agent handoff
694 declarations in YAML frontmatter to compute the transitive closure of
695 all agents reachable through handoff chains.
696 .PARAMETER SeedAgents
697 Initial agent names to start BFS from.
698 .PARAMETER AgentsDir
699 Path to the agents directory containing .agent.md files.
700 .OUTPUTS
701 [string[]] Complete set of agent names including seed agents and all transitive handoff targets.
702 #>
703 [CmdletBinding()]
704 [OutputType([string[]])]
705 param(
706 [Parameter(Mandatory = $true)]
707 [string[]]$SeedAgents,
708
709 [Parameter(Mandatory = $true)]
710 [string]$AgentsDir
711 )
712
713 $visited = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
714 $queue = [System.Collections.Generic.Queue[string]]::new()
715
716 foreach ($agent in $SeedAgents) {
717 if ($visited.Add($agent)) {
718 $queue.Enqueue($agent)
719 }
720 }
721
722 while ($queue.Count -gt 0) {
723 $current = $queue.Dequeue()
724 $agentFileMatches = Get-ChildItem -Path $AgentsDir -Filter "$current.agent.md" -Recurse -File
725 $agentFile = $agentFileMatches | Select-Object -First 1
726
727 if (-not $agentFile) {
728 Write-Warning "Handoff target agent file not found: $current.agent.md"
729 continue
730 }
731
732 # Parse handoffs from frontmatter
733 $content = Get-Content -Path $agentFile.FullName -Raw
734 if ($content -match '(?s)^---\s*\r?\n(.*?)\r?\n---') {
735 $yamlContent = $Matches[1] -replace '\r\n', "`n" -replace '\r', "`n"
736 try {
737 $data = ConvertFrom-Yaml -Yaml $yamlContent
738 if ($data.ContainsKey('handoffs') -and $data.handoffs -is [System.Collections.IEnumerable] -and $data.handoffs -isnot [string]) {
739 foreach ($handoff in $data.handoffs) {
740 # Handle both string format and object format (with 'agent' field).
741 # Handoff targets bypass maturity filtering by design.
742 # See docs/contributing/ai-artifacts-common.md
743 # "Handoff vs Requires Maturity Filtering" for rationale.
744 $targetAgent = $null
745 if ($handoff -is [string]) {
746 $targetAgent = $handoff
747 } elseif ($handoff -is [hashtable] -and $handoff.ContainsKey('agent')) {
748 $targetAgent = $handoff.agent
749 }
750 if ($targetAgent -and $visited.Add($targetAgent)) {
751 $queue.Enqueue($targetAgent)
752 }
753 }
754 }
755 }
756 catch {
757 Write-Warning "Failed to parse handoffs from $current.agent.md: $_"
758 }
759 }
760 }
761
762 return @($visited)
763}
764
765function Resolve-RequiresDependencies {
766 <#
767 .SYNOPSIS
768 Resolves transitive artifact dependencies from collection item requires blocks.
769 .DESCRIPTION
770 Walks requires blocks in collection items to compute the complete set of
771 dependent artifacts across all types (agents, prompts, instructions, skills).
772 .PARAMETER ArtifactNames
773 Hashtable with initial artifact name arrays keyed by type (agents, prompts, instructions, skills).
774 .PARAMETER AllowedMaturities
775 Array of maturity levels to include.
776 .PARAMETER CollectionRequires
777 Per-type map of artifact requires blocks keyed by artifact name.
778 .PARAMETER CollectionMaturities
779 Optional per-type maturity map keyed by artifact name.
780 .OUTPUTS
781 [hashtable] With Agents, Prompts, Instructions, Skills arrays containing resolved names.
782 #>
783 [CmdletBinding()]
784 [OutputType([hashtable])]
785 param(
786 [Parameter(Mandatory = $true)]
787 [hashtable]$ArtifactNames,
788
789 [Parameter(Mandatory = $true)]
790 [string[]]$AllowedMaturities,
791
792 [Parameter(Mandatory = $false)]
793 [hashtable]$CollectionRequires = @{},
794
795 [Parameter(Mandatory = $false)]
796 [hashtable]$CollectionMaturities = @{}
797 )
798
799 $resolved = @{
800 Agents = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
801 Prompts = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
802 Instructions = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
803 Skills = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
804 }
805
806 $typeMap = @{
807 agents = 'Agents'
808 prompts = 'Prompts'
809 instructions = 'Instructions'
810 skills = 'Skills'
811 }
812
813 # Seed with initial artifact names
814 foreach ($type in @('agents', 'prompts', 'instructions', 'skills')) {
815 $capitalType = $typeMap[$type]
816 if ($ArtifactNames.ContainsKey($type)) {
817 foreach ($name in $ArtifactNames[$type]) {
818 $null = $resolved[$capitalType].Add($name)
819 }
820 }
821 }
822
823 $changed = $true
824 while ($changed) {
825 $changed = $false
826
827 foreach ($sourceType in @('agents', 'prompts', 'instructions', 'skills')) {
828 if (-not $CollectionRequires.ContainsKey($sourceType)) {
829 continue
830 }
831
832 $sourceCapitalType = $typeMap[$sourceType]
833 foreach ($sourceName in @($resolved[$sourceCapitalType])) {
834 if (-not $CollectionRequires[$sourceType].ContainsKey($sourceName)) {
835 continue
836 }
837
838 $requires = $CollectionRequires[$sourceType][$sourceName]
839 if (-not $requires) {
840 continue
841 }
842
843 foreach ($targetType in @('agents', 'prompts', 'instructions', 'skills')) {
844 if (-not $requires.ContainsKey($targetType)) {
845 continue
846 }
847
848 $targetCapitalType = $typeMap[$targetType]
849 foreach ($dep in @($requires[$targetType])) {
850 $depMaturity = 'stable'
851 if ($CollectionMaturities.ContainsKey($targetType) -and $CollectionMaturities[$targetType].ContainsKey($dep)) {
852 $depMaturity = $CollectionMaturities[$targetType][$dep]
853 }
854
855 if ($AllowedMaturities -notcontains $depMaturity) {
856 continue
857 }
858
859 if ($resolved[$targetCapitalType].Add($dep)) {
860 $changed = $true
861 }
862 }
863 }
864 }
865 }
866 }
867
868 # Convert HashSets to arrays
869 return @{
870 Agents = @($resolved.Agents)
871 Prompts = @($resolved.Prompts)
872 Instructions = @($resolved.Instructions)
873 Skills = @($resolved.Skills)
874 }
875}
876
877function Test-PathsExist {
878 <#
879 .SYNOPSIS
880 Validates that required paths exist for extension preparation.
881 .DESCRIPTION
882 Validation function that checks whether extension directory, package.json,
883 and .github directory exist at the specified locations.
884 .PARAMETER ExtensionDir
885 Path to the extension directory.
886 .PARAMETER PackageJsonPath
887 Path to package.json file.
888 .PARAMETER GitHubDir
889 Path to .github directory.
890 .OUTPUTS
891 [hashtable] With IsValid bool, MissingPaths array, and ErrorMessages array.
892 #>
893 [CmdletBinding()]
894 [OutputType([hashtable])]
895 param(
896 [Parameter(Mandatory = $true)]
897 [string]$ExtensionDir,
898
899 [Parameter(Mandatory = $true)]
900 [string]$PackageJsonPath,
901
902 [Parameter(Mandatory = $true)]
903 [string]$GitHubDir
904 )
905
906 $missingPaths = @()
907 $errorMessages = @()
908
909 if (-not (Test-Path $ExtensionDir)) {
910 $missingPaths += $ExtensionDir
911 $errorMessages += "Extension directory not found: $ExtensionDir"
912 }
913 if (-not (Test-Path $PackageJsonPath)) {
914 $missingPaths += $PackageJsonPath
915 $errorMessages += "package.json not found: $PackageJsonPath"
916 }
917 if (-not (Test-Path $GitHubDir)) {
918 $missingPaths += $GitHubDir
919 $errorMessages += ".github directory not found: $GitHubDir"
920 }
921
922 return @{
923 IsValid = ($missingPaths.Count -eq 0)
924 MissingPaths = $missingPaths
925 ErrorMessages = $errorMessages
926 }
927}
928
929function Get-DiscoveredAgents {
930 <#
931 .SYNOPSIS
932 Discovers chat agent files from the agents directory.
933 .DESCRIPTION
934 Discovery function that scans the agents directory for .agent.md files,
935 filters by exclusion list, and returns structured agent objects.
936 .PARAMETER AgentsDir
937 Path to the agents directory.
938 .PARAMETER AllowedMaturities
939 Array of maturity levels to include.
940 .PARAMETER ExcludedAgents
941 Array of agent names to exclude from packaging.
942 .OUTPUTS
943 [hashtable] With Agents array, Skipped array, and DirectoryExists bool.
944 #>
945 [CmdletBinding()]
946 [OutputType([hashtable])]
947 param(
948 [Parameter(Mandatory = $true)]
949 [string]$AgentsDir,
950
951 [Parameter(Mandatory = $true)]
952 [string[]]$AllowedMaturities,
953
954 [Parameter(Mandatory = $false)]
955 [string[]]$ExcludedAgents = @()
956 )
957
958 $result = @{
959 Agents = @()
960 Skipped = @()
961 DirectoryExists = (Test-Path $AgentsDir)
962 }
963
964 if (-not $result.DirectoryExists) {
965 return $result
966 }
967
968 $agentFiles = Get-ChildItem -Path $AgentsDir -Filter "*.agent.md" -Recurse | Sort-Object Name
969 $agentFiles = $agentFiles | Where-Object { -not (Test-DeprecatedPath -Path $_.FullName) }
970
971 foreach ($agentFile in $agentFiles) {
972 $agentRelPath = [System.IO.Path]::GetRelativePath($AgentsDir, $agentFile.FullName) -replace '\\', '/'
973
974 if (Test-HveCoreRepoSpecificPath -RelativePath $agentRelPath) {
975 $agentName = $agentFile.BaseName -replace '\.agent$', ''
976 $result.Skipped += @{ Name = $agentName; Reason = 'repo-specific (root-level)' }
977 continue
978 }
979
980 $agentName = $agentFile.BaseName -replace '\.agent$', ''
981
982 if ($ExcludedAgents -contains $agentName) {
983 $result.Skipped += @{ Name = $agentName; Reason = 'excluded' }
984 continue
985 }
986
987 $maturity = "stable"
988
989 if ($AllowedMaturities -notcontains $maturity) {
990 $result.Skipped += @{ Name = $agentName; Reason = "maturity: $maturity" }
991 continue
992 }
993 $result.Agents += [PSCustomObject]@{
994 name = $agentName
995 path = "./.github/agents/$agentRelPath"
996 }
997 }
998
999 return $result
1000}
1001
1002function Get-DiscoveredPrompts {
1003 <#
1004 .SYNOPSIS
1005 Discovers prompt files from the prompts directory.
1006 .DESCRIPTION
1007 Discovery function that scans the prompts directory for .prompt.md files,
1008 and returns structured prompt objects with relative paths.
1009 .PARAMETER PromptsDir
1010 Path to the prompts directory.
1011 .PARAMETER GitHubDir
1012 Path to the .github directory for relative path calculation.
1013 .PARAMETER AllowedMaturities
1014 Array of maturity levels to include.
1015 .OUTPUTS
1016 [hashtable] With Prompts array, Skipped array, and DirectoryExists bool.
1017 #>
1018 [CmdletBinding()]
1019 [OutputType([hashtable])]
1020 param(
1021 [Parameter(Mandatory = $true)]
1022 [string]$PromptsDir,
1023
1024 [Parameter(Mandatory = $true)]
1025 [string]$GitHubDir,
1026
1027 [Parameter(Mandatory = $true)]
1028 [string[]]$AllowedMaturities
1029 )
1030
1031 $result = @{
1032 Prompts = @()
1033 Skipped = @()
1034 DirectoryExists = (Test-Path $PromptsDir)
1035 }
1036
1037 if (-not $result.DirectoryExists) {
1038 return $result
1039 }
1040
1041 $promptFiles = Get-ChildItem -Path $PromptsDir -Filter "*.prompt.md" -Recurse | Sort-Object Name
1042 $promptFiles = $promptFiles | Where-Object { -not (Test-DeprecatedPath -Path $_.FullName) }
1043
1044 foreach ($promptFile in $promptFiles) {
1045 $promptName = $promptFile.BaseName -replace '\.prompt$', ''
1046
1047 $promptRelPath = [System.IO.Path]::GetRelativePath($PromptsDir, $promptFile.FullName) -replace '\\', '/'
1048 if (Test-HveCoreRepoSpecificPath -RelativePath $promptRelPath) {
1049 $result.Skipped += @{ Name = $promptName; Reason = 'repo-specific (root-level)' }
1050 continue
1051 }
1052
1053 $maturity = "stable"
1054
1055 if ($AllowedMaturities -notcontains $maturity) {
1056 $result.Skipped += @{ Name = $promptName; Reason = "maturity: $maturity" }
1057 continue
1058 }
1059
1060 $relativePath = [System.IO.Path]::GetRelativePath($GitHubDir, $promptFile.FullName) -replace '\\', '/'
1061
1062 $result.Prompts += [PSCustomObject]@{
1063 name = $promptName
1064 path = "./.github/$relativePath"
1065 }
1066 }
1067
1068 return $result
1069}
1070
1071function Get-DiscoveredInstructions {
1072 <#
1073 .SYNOPSIS
1074 Discovers instruction files from the instructions directory.
1075 .DESCRIPTION
1076 Discovery function that scans the instructions directory for .instructions.md files,
1077 and returns structured instruction objects with normalized paths.
1078 .PARAMETER InstructionsDir
1079 Path to the instructions directory.
1080 .PARAMETER GitHubDir
1081 Path to the .github directory for relative path calculation.
1082 .PARAMETER AllowedMaturities
1083 Array of maturity levels to include.
1084 .OUTPUTS
1085 [hashtable] With Instructions array, Skipped array, and DirectoryExists bool.
1086 #>
1087 [CmdletBinding()]
1088 [OutputType([hashtable])]
1089 param(
1090 [Parameter(Mandatory = $true)]
1091 [string]$InstructionsDir,
1092
1093 [Parameter(Mandatory = $true)]
1094 [string]$GitHubDir,
1095
1096 [Parameter(Mandatory = $true)]
1097 [string[]]$AllowedMaturities
1098 )
1099
1100 $result = @{
1101 Instructions = @()
1102 Skipped = @()
1103 DirectoryExists = (Test-Path $InstructionsDir)
1104 }
1105
1106 if (-not $result.DirectoryExists) {
1107 return $result
1108 }
1109
1110 $instructionFiles = Get-ChildItem -Path $InstructionsDir -Filter "*.instructions.md" -Recurse | Sort-Object Name
1111 $instructionFiles = $instructionFiles | Where-Object { -not (Test-DeprecatedPath -Path $_.FullName) }
1112
1113 foreach ($instrFile in $instructionFiles) {
1114 $instrRelPath = [System.IO.Path]::GetRelativePath($InstructionsDir, $instrFile.FullName) -replace '\\', '/'
1115 if (Test-HveCoreRepoSpecificPath -RelativePath $instrRelPath) {
1116 $result.Skipped += @{ Name = $instrFile.BaseName; Reason = 'repo-specific (root-level)' }
1117 continue
1118 }
1119 $baseName = $instrFile.BaseName -replace '\.instructions$', ''
1120 $instrName = "$baseName-instructions"
1121
1122 $maturity = "stable"
1123
1124 if ($AllowedMaturities -notcontains $maturity) {
1125 $result.Skipped += @{ Name = $instrName; Reason = "maturity: $maturity" }
1126 continue
1127 }
1128
1129 $relativePathFromGitHub = [System.IO.Path]::GetRelativePath($GitHubDir, $instrFile.FullName)
1130 $normalizedRelativePath = (Join-Path ".github" $relativePathFromGitHub) -replace '\\', '/'
1131
1132 $result.Instructions += [PSCustomObject]@{
1133 name = $instrName
1134 path = "./$normalizedRelativePath"
1135 }
1136 }
1137
1138 return $result
1139}
1140
1141function Get-DiscoveredSkills {
1142 <#
1143 .SYNOPSIS
1144 Discovers skill packages from the skills directory.
1145 .DESCRIPTION
1146 Discovery function that scans the skills directory for subdirectories
1147 containing SKILL.md files and returns structured skill objects.
1148 .PARAMETER SkillsDir
1149 Path to the skills directory.
1150 .PARAMETER AllowedMaturities
1151 Array of maturity levels to include.
1152 .OUTPUTS
1153 [hashtable] With Skills array, Skipped array, and DirectoryExists bool.
1154 #>
1155 [CmdletBinding()]
1156 [OutputType([hashtable])]
1157 param(
1158 [Parameter(Mandatory = $true)]
1159 [string]$SkillsDir,
1160
1161 [Parameter(Mandatory = $true)]
1162 [string[]]$AllowedMaturities
1163 )
1164
1165 $result = @{
1166 Skills = @()
1167 Skipped = @()
1168 DirectoryExists = (Test-Path $SkillsDir)
1169 }
1170
1171 if (-not $result.DirectoryExists) {
1172 return $result
1173 }
1174
1175 $skillFiles = Get-ChildItem -Path $SkillsDir -Filter "SKILL.md" -File -Recurse | Sort-Object { $_.Directory.FullName }
1176 $skillFiles = $skillFiles | Where-Object { -not (Test-DeprecatedPath -Path $_.FullName) }
1177
1178 foreach ($skillFile in $skillFiles) {
1179 $skillDir = $skillFile.Directory
1180 $skillName = $skillDir.Name
1181 $skillRelPath = [System.IO.Path]::GetRelativePath($SkillsDir, $skillDir.FullName) -replace '\\', '/'
1182
1183 if (Test-HveCoreRepoSpecificPath -RelativePath $skillRelPath) {
1184 $result.Skipped += @{ Name = $skillName; Reason = 'repo-specific (root-level)' }
1185 continue
1186 }
1187
1188 $maturity = "stable"
1189
1190 if ($AllowedMaturities -notcontains $maturity) {
1191 $result.Skipped += @{ Name = $skillName; Reason = "maturity: $maturity" }
1192 continue
1193 }
1194
1195 $result.Skills += [PSCustomObject]@{
1196 name = $skillName
1197 path = "./.github/skills/$skillRelPath/SKILL.md"
1198 }
1199 }
1200
1201 return $result
1202}
1203
1204function Update-PackageJsonContributes {
1205 <#
1206 .SYNOPSIS
1207 Updates package.json contributes section with discovered components.
1208 .DESCRIPTION
1209 Pure function that takes a package.json object and discovered components,
1210 returning a new object with the contributes section updated. Handles
1211 chatAgents, chatPromptFiles, chatInstructions, and chatSkills.
1212 .PARAMETER PackageJson
1213 The package.json object to update.
1214 .PARAMETER ChatAgents
1215 Array of discovered chat agent objects.
1216 .PARAMETER ChatPromptFiles
1217 Array of discovered prompt objects.
1218 .PARAMETER ChatInstructions
1219 Array of discovered instruction objects.
1220 .PARAMETER ChatSkills
1221 Array of discovered skill objects.
1222 .OUTPUTS
1223 [PSCustomObject] Updated package.json object.
1224 #>
1225 [CmdletBinding()]
1226 [OutputType([PSCustomObject])]
1227 param(
1228 [Parameter(Mandatory = $true)]
1229 [PSCustomObject]$PackageJson,
1230
1231 [Parameter(Mandatory = $true)]
1232 [AllowEmptyCollection()]
1233 [array]$ChatAgents,
1234
1235 [Parameter(Mandatory = $true)]
1236 [AllowEmptyCollection()]
1237 [array]$ChatPromptFiles,
1238
1239 [Parameter(Mandatory = $true)]
1240 [AllowEmptyCollection()]
1241 [array]$ChatInstructions,
1242
1243 [Parameter(Mandatory = $true)]
1244 [AllowEmptyCollection()]
1245 [array]$ChatSkills
1246 )
1247
1248 # Clone the object to avoid modifying the original
1249 $updated = $PackageJson | ConvertTo-Json -Depth 10 | ConvertFrom-Json
1250
1251 # Strip name and description; VS Code reads these from the files directly
1252 $ChatAgents = @($ChatAgents | Select-Object -Property path)
1253 $ChatPromptFiles = @($ChatPromptFiles | Select-Object -Property path)
1254 $ChatInstructions = @($ChatInstructions | Select-Object -Property path)
1255 $ChatSkills = @($ChatSkills | Select-Object -Property path)
1256
1257 # Ensure contributes section exists
1258 if (-not $updated.contributes) {
1259 $updated | Add-Member -NotePropertyName "contributes" -NotePropertyValue ([PSCustomObject]@{})
1260 }
1261
1262 # Add or update contributes properties
1263 if ($null -eq $updated.contributes.chatAgents) {
1264 $updated.contributes | Add-Member -NotePropertyName "chatAgents" -NotePropertyValue $ChatAgents -Force
1265 } else {
1266 $updated.contributes.chatAgents = $ChatAgents
1267 }
1268
1269 if ($null -eq $updated.contributes.chatPromptFiles) {
1270 $updated.contributes | Add-Member -NotePropertyName "chatPromptFiles" -NotePropertyValue $ChatPromptFiles -Force
1271 } else {
1272 $updated.contributes.chatPromptFiles = $ChatPromptFiles
1273 }
1274
1275 if ($null -eq $updated.contributes.chatInstructions) {
1276 $updated.contributes | Add-Member -NotePropertyName "chatInstructions" -NotePropertyValue $ChatInstructions -Force
1277 } else {
1278 $updated.contributes.chatInstructions = $ChatInstructions
1279 }
1280
1281 if ($null -eq $updated.contributes.chatSkills) {
1282 $updated.contributes | Add-Member -NotePropertyName "chatSkills" -NotePropertyValue $ChatSkills -Force
1283 } else {
1284 $updated.contributes.chatSkills = $ChatSkills
1285 }
1286
1287 return $updated
1288}
1289
1290function New-PrepareResult {
1291 <#
1292 .SYNOPSIS
1293 Creates a standardized result object for extension preparation operations.
1294 .DESCRIPTION
1295 Factory function that creates a hashtable with consistent properties
1296 for reporting preparation operation outcomes.
1297 .PARAMETER Success
1298 Indicates whether the operation completed successfully.
1299 .PARAMETER Version
1300 The version string from package.json.
1301 .PARAMETER AgentCount
1302 Number of agents discovered and included.
1303 .PARAMETER PromptCount
1304 Number of prompts discovered and included.
1305 .PARAMETER InstructionCount
1306 Number of instructions discovered and included.
1307 .PARAMETER SkillCount
1308 Number of skills discovered and included.
1309 .PARAMETER ErrorMessage
1310 Error description when Success is false.
1311 .OUTPUTS
1312 Hashtable with Success, Version, AgentCount, PromptCount,
1313 InstructionCount, SkillCount, and ErrorMessage properties.
1314 #>
1315 [CmdletBinding()]
1316 [OutputType([hashtable])]
1317 param(
1318 [Parameter(Mandatory = $true)]
1319 [bool]$Success,
1320
1321 [Parameter(Mandatory = $false)]
1322 [string]$Version = "",
1323
1324 [Parameter(Mandatory = $false)]
1325 [int]$AgentCount = 0,
1326
1327 [Parameter(Mandatory = $false)]
1328 [int]$PromptCount = 0,
1329
1330 [Parameter(Mandatory = $false)]
1331 [int]$InstructionCount = 0,
1332
1333 [Parameter(Mandatory = $false)]
1334 [int]$SkillCount = 0,
1335
1336 [Parameter(Mandatory = $false)]
1337 [string]$ErrorMessage = ""
1338 )
1339
1340 return @{
1341 Success = $Success
1342 Version = $Version
1343 AgentCount = $AgentCount
1344 PromptCount = $PromptCount
1345 InstructionCount = $InstructionCount
1346 SkillCount = $SkillCount
1347 ErrorMessage = $ErrorMessage
1348 }
1349}
1350
1351function Test-TemplateConsistency {
1352 <#
1353 .SYNOPSIS
1354 Validates collection template metadata against its collection manifest.
1355 .DESCRIPTION
1356 Compares name, displayName, and description fields between a collection
1357 package template (e.g. package.developer.json) and the corresponding
1358 collection manifest. Emits warnings for divergences and returns a list
1359 of mismatches.
1360 .PARAMETER TemplatePath
1361 Path to the collection package template JSON file.
1362 .PARAMETER CollectionManifest
1363 Parsed collection manifest hashtable with name, displayName, description.
1364 .OUTPUTS
1365 [hashtable] With Mismatches array and IsConsistent bool.
1366 #>
1367 [CmdletBinding()]
1368 [OutputType([hashtable])]
1369 param(
1370 [Parameter(Mandatory = $true)]
1371 [ValidateNotNullOrEmpty()]
1372 [string]$TemplatePath,
1373
1374 [Parameter(Mandatory = $true)]
1375 [hashtable]$CollectionManifest
1376 )
1377
1378 $result = @{
1379 Mismatches = @()
1380 IsConsistent = $true
1381 }
1382
1383 if (-not (Test-Path $TemplatePath)) {
1384 $result.Mismatches += @{
1385 Field = 'file'
1386 Template = $TemplatePath
1387 Manifest = 'N/A'
1388 Message = "Template file not found: $TemplatePath"
1389 }
1390 $result.IsConsistent = $false
1391 return $result
1392 }
1393
1394 try {
1395 $template = Get-Content -Path $TemplatePath -Raw | ConvertFrom-Json
1396 }
1397 catch {
1398 $result.Mismatches += @{
1399 Field = 'file'
1400 Template = $TemplatePath
1401 Manifest = 'N/A'
1402 Message = "Failed to parse template: $($_.Exception.Message)"
1403 }
1404 $result.IsConsistent = $false
1405 return $result
1406 }
1407
1408 $fieldsToCheck = @('name', 'displayName', 'description')
1409 foreach ($field in $fieldsToCheck) {
1410 $templateValue = $null
1411 $manifestValue = $null
1412
1413 if ($template.PSObject.Properties[$field]) {
1414 $templateValue = $template.$field
1415 }
1416 if ($CollectionManifest.ContainsKey($field)) {
1417 $manifestValue = $CollectionManifest[$field]
1418 }
1419
1420 if ($null -ne $templateValue -and $null -ne $manifestValue -and $templateValue -ne $manifestValue) {
1421 $result.Mismatches += @{
1422 Field = $field
1423 Template = $templateValue
1424 Manifest = $manifestValue
1425 Message = "$field diverges: template='$templateValue' manifest='$manifestValue'"
1426 }
1427 $result.IsConsistent = $false
1428 }
1429 }
1430
1431 return $result
1432}
1433
1434function Invoke-PrepareExtension {
1435 <#
1436 .SYNOPSIS
1437 Orchestrates VS Code extension preparation with full error handling.
1438 .DESCRIPTION
1439 Executes the complete preparation workflow: validates paths, discovers
1440 agents/prompts/instructions, updates package.json, and handles changelog.
1441 Returns a result object instead of using exit codes.
1442 .PARAMETER ExtensionDirectory
1443 Absolute path to the extension directory containing package.json.
1444 .PARAMETER RepoRoot
1445 Absolute path to the repository root directory.
1446 .PARAMETER Channel
1447 Release channel controlling maturity filter ('Stable' or 'PreRelease').
1448 .PARAMETER ChangelogPath
1449 Optional path to changelog file to include.
1450 .PARAMETER DryRun
1451 When specified, shows what would be done without making changes.
1452 .OUTPUTS
1453 Hashtable with Success, Version, AgentCount, PromptCount,
1454 InstructionCount, SkillCount, and ErrorMessage properties.
1455 #>
1456 [CmdletBinding()]
1457 [OutputType([hashtable])]
1458 param(
1459 [Parameter(Mandatory = $true)]
1460 [ValidateNotNullOrEmpty()]
1461 [string]$ExtensionDirectory,
1462
1463 [Parameter(Mandatory = $true)]
1464 [ValidateNotNullOrEmpty()]
1465 [string]$RepoRoot,
1466
1467 [Parameter(Mandatory = $false)]
1468 [ValidateSet('Stable', 'PreRelease')]
1469 [string]$Channel = 'Stable',
1470
1471 [Parameter(Mandatory = $false)]
1472 [string]$ChangelogPath = "",
1473
1474 [Parameter(Mandatory = $false)]
1475 [switch]$DryRun,
1476
1477 [Parameter(Mandatory = $false)]
1478 [string]$Collection = ""
1479 )
1480
1481 # Derive paths
1482 $GitHubDir = Join-Path $RepoRoot ".github"
1483 $PackageJsonPath = Join-Path $ExtensionDirectory "package.json"
1484
1485 # Generate collection package files from root collection manifests.
1486 # This ensures extension/package.json and extension/package.*.json exist
1487 # with the correct version from the template before any reads occur.
1488 try {
1489 $generated = Invoke-ExtensionCollectionsGeneration -RepoRoot $RepoRoot
1490 Write-Host "Generated $($generated.Count) collection package file(s)" -ForegroundColor Green
1491 }
1492 catch {
1493 return New-PrepareResult -Success $false -ErrorMessage "Package generation failed: $($_.Exception.Message)"
1494 }
1495
1496 # Validate required paths exist (package.json now guaranteed by generation)
1497 $pathValidation = Test-PathsExist -ExtensionDir $ExtensionDirectory `
1498 -PackageJsonPath $PackageJsonPath `
1499 -GitHubDir $GitHubDir
1500 if (-not $pathValidation.IsValid) {
1501 $missingPaths = $pathValidation.MissingPaths -join ', '
1502 return New-PrepareResult -Success $false -ErrorMessage "Required paths not found: $missingPaths"
1503 }
1504
1505 # Read and parse package.json
1506 try {
1507 $packageJsonContent = Get-Content -Path $PackageJsonPath -Raw
1508 $packageJson = $packageJsonContent | ConvertFrom-Json
1509 }
1510 catch {
1511 return New-PrepareResult -Success $false -ErrorMessage "Failed to parse package.json at '$PackageJsonPath'. Check the file for JSON syntax errors. Underlying error: $($_.Exception.Message)"
1512 }
1513
1514 # Validate version field
1515 if (-not $packageJson.PSObject.Properties['version']) {
1516 return New-PrepareResult -Success $false -ErrorMessage "package.json does not contain a 'version' field"
1517 }
1518 $version = $packageJson.version
1519 if ($version -notmatch '^\d+\.\d+\.\d+$') {
1520 return New-PrepareResult -Success $false -ErrorMessage "Invalid version format in package.json: $version"
1521 }
1522
1523 # Get allowed maturities for channel
1524 $allowedMaturities = Get-AllowedMaturities -Channel $Channel
1525
1526 Write-Host "`n=== Prepare Extension ===" -ForegroundColor Cyan
1527 Write-Host "Extension Directory: $ExtensionDirectory"
1528 Write-Host "Repository Root: $RepoRoot"
1529 Write-Host "Channel: $Channel"
1530 Write-Host "Allowed Maturities: $($allowedMaturities -join ', ')"
1531 Write-Host "Version: $version"
1532 if ($DryRun) {
1533 Write-Host "[DRY RUN] No changes will be made" -ForegroundColor Yellow
1534 }
1535
1536 # Load collection manifest if specified
1537 $collectionManifest = $null
1538 $collectionArtifactNames = $null
1539 $collectionMaturities = @{}
1540 $collectionRequires = @{}
1541
1542 if ($Collection -and $Collection -ne "") {
1543 $collectionManifest = Get-CollectionManifest -CollectionPath $Collection
1544 Write-Host "Collection: $($collectionManifest.displayName) ($($collectionManifest.id))"
1545
1546 $artifactCollectionManifest = $collectionManifest
1547 if (-not $artifactCollectionManifest.ContainsKey('items') -or @($artifactCollectionManifest.items).Count -eq 0) {
1548 # When the manifest lacks items (e.g., a generated JSON template),
1549 # resolve from the root YAML collection by ID.
1550 $rootCollectionPath = Join-Path $RepoRoot "collections/$($collectionManifest.id).collection.yml"
1551 if (Test-Path $rootCollectionPath) {
1552 $artifactCollectionManifest = Get-CollectionManifest -CollectionPath $rootCollectionPath
1553 Write-Host "Using root collection for items: $rootCollectionPath"
1554 }
1555 else {
1556 Write-Warning "No root collection found for '$($collectionManifest.id)' at $rootCollectionPath"
1557 }
1558 }
1559
1560 # Check collection-level maturity eligibility
1561 $collectionEligibility = Test-CollectionMaturityEligible -CollectionManifest $collectionManifest -Channel $Channel
1562 if (-not $collectionEligibility.IsEligible) {
1563 Write-Host "`n⏭️ $($collectionEligibility.Reason)" -ForegroundColor Yellow
1564 return New-PrepareResult -Success $true -Version $version
1565 }
1566
1567 $collectionMaturity = if ($collectionManifest.ContainsKey('maturity')) { $collectionManifest['maturity'] } else { 'stable' }
1568 Write-Host "Collection maturity: $collectionMaturity"
1569
1570 # Build collection maturity map and channel-filtered artifact names
1571 $collectionMaturities = @{}
1572 $collectionRequires = @{}
1573
1574 if ($artifactCollectionManifest.ContainsKey('items')) {
1575 foreach ($item in $artifactCollectionManifest.items) {
1576 if (-not $item.ContainsKey('kind') -or -not $item.ContainsKey('path')) {
1577 continue
1578 }
1579
1580 $itemKind = [string]$item.kind
1581 $itemPath = [string]$item.path
1582 $artifactKey = Get-CollectionArtifactKey -Kind $itemKind -Path $itemPath
1583 $effectiveMaturity = Resolve-CollectionItemMaturity -Maturity $item.maturity
1584 if (-not $collectionMaturities.ContainsKey("${itemKind}s") -or $null -eq $collectionMaturities["${itemKind}s"]) {
1585 $collectionMaturities["${itemKind}s"] = @{}
1586 }
1587 $collectionMaturities["${itemKind}s"][$artifactKey] = $effectiveMaturity
1588
1589 if ($item.ContainsKey('requires') -and $item.requires) {
1590 if (-not $collectionRequires.ContainsKey("${itemKind}s") -or $null -eq $collectionRequires["${itemKind}s"]) {
1591 $collectionRequires["${itemKind}s"] = @{}
1592 }
1593 $collectionRequires["${itemKind}s"][$artifactKey] = $item.requires
1594 }
1595 }
1596 }
1597
1598 $collectionArtifactNames = Get-CollectionArtifacts -Collection $artifactCollectionManifest -AllowedMaturities $allowedMaturities
1599
1600 # Resolve handoff dependencies (agents only)
1601 if (@($collectionArtifactNames.Agents).Count -gt 0) {
1602 $agentsDir = Join-Path $GitHubDir "agents"
1603 $expandedAgents = Resolve-HandoffDependencies -SeedAgents $collectionArtifactNames.Agents -AgentsDir $agentsDir
1604 $collectionArtifactNames.Agents = $expandedAgents
1605 }
1606
1607 # Resolve requires dependencies
1608 $resolvedNames = Resolve-RequiresDependencies -ArtifactNames @{
1609 agents = $collectionArtifactNames.Agents
1610 prompts = $collectionArtifactNames.Prompts
1611 instructions = $collectionArtifactNames.Instructions
1612 skills = $collectionArtifactNames.Skills
1613 } -AllowedMaturities $allowedMaturities -CollectionRequires $collectionRequires -CollectionMaturities $collectionMaturities
1614
1615 $collectionArtifactNames = @{
1616 Agents = $resolvedNames.Agents
1617 Prompts = $resolvedNames.Prompts
1618 Instructions = $resolvedNames.Instructions
1619 Skills = $resolvedNames.Skills
1620 }
1621 }
1622
1623 # Discover artifacts
1624 $discoveryAllowedMaturities = if ($null -ne $collectionArtifactNames) {
1625 @('stable', 'preview', 'experimental', 'deprecated')
1626 }
1627 else {
1628 $allowedMaturities
1629 }
1630
1631 $agentsDir = Join-Path $GitHubDir "agents"
1632 $agentResult = Get-DiscoveredAgents -AgentsDir $agentsDir -AllowedMaturities $discoveryAllowedMaturities -ExcludedAgents @()
1633 $chatAgents = $agentResult.Agents
1634 $excludedAgents = $agentResult.Skipped
1635
1636 Write-Host "`n--- Chat Agents ---" -ForegroundColor Green
1637 Write-Host "Found $($chatAgents.Count) agent(s) matching criteria"
1638 if ($excludedAgents.Count -gt 0) {
1639 Write-Host "Excluded $($excludedAgents.Count) agent(s) due to maturity filter" -ForegroundColor Yellow
1640 }
1641
1642 # Discover prompts
1643 $promptsDir = Join-Path $GitHubDir "prompts"
1644 $promptResult = Get-DiscoveredPrompts -PromptsDir $promptsDir -GitHubDir $GitHubDir -AllowedMaturities $discoveryAllowedMaturities
1645 $chatPrompts = $promptResult.Prompts
1646 $excludedPrompts = $promptResult.Skipped
1647
1648 Write-Host "`n--- Chat Prompts ---" -ForegroundColor Green
1649 Write-Host "Found $($chatPrompts.Count) prompt(s) matching criteria"
1650 if ($excludedPrompts.Count -gt 0) {
1651 Write-Host "Excluded $($excludedPrompts.Count) prompt(s) due to maturity filter" -ForegroundColor Yellow
1652 }
1653
1654 # Discover instructions
1655 $instructionsDir = Join-Path $GitHubDir "instructions"
1656 $instructionResult = Get-DiscoveredInstructions -InstructionsDir $instructionsDir -GitHubDir $GitHubDir -AllowedMaturities $discoveryAllowedMaturities
1657 $chatInstructions = $instructionResult.Instructions
1658 $excludedInstructions = $instructionResult.Skipped
1659
1660 Write-Host "`n--- Chat Instructions ---" -ForegroundColor Green
1661 Write-Host "Found $($chatInstructions.Count) instruction(s) matching criteria"
1662 if ($excludedInstructions.Count -gt 0) {
1663 Write-Host "Excluded $($excludedInstructions.Count) instruction(s) due to maturity filter" -ForegroundColor Yellow
1664 }
1665
1666 # Discover skills
1667 $skillsDir = Join-Path $GitHubDir "skills"
1668 $skillResult = Get-DiscoveredSkills -SkillsDir $skillsDir -AllowedMaturities $discoveryAllowedMaturities
1669 $chatSkills = $skillResult.Skills
1670 $excludedSkills = $skillResult.Skipped
1671
1672 Write-Host "`n--- Chat Skills ---" -ForegroundColor Green
1673 Write-Host "Found $($chatSkills.Count) skill(s) matching criteria"
1674 if ($excludedSkills.Count -gt 0) {
1675 Write-Host "Excluded $($excludedSkills.Count) skill(s) due to maturity filter" -ForegroundColor Yellow
1676 }
1677
1678 # Apply collection filtering to discovered artifacts
1679 if ($null -ne $collectionArtifactNames) {
1680 $chatAgents = @($chatAgents | Where-Object { $collectionArtifactNames.Agents -contains $_.name })
1681 $chatPrompts = @($chatPrompts | Where-Object { $collectionArtifactNames.Prompts -contains $_.name })
1682 $instrBaseNames = @($collectionArtifactNames.Instructions | ForEach-Object { ($_ -split '/')[-1] })
1683 $chatInstructions = @($chatInstructions | Where-Object {
1684 $instrBaseName = $_.name -replace '-instructions$', ''
1685 $instrBaseNames -contains $instrBaseName
1686 })
1687 $chatSkills = @($chatSkills | Where-Object { $collectionArtifactNames.Skills -contains $_.name })
1688
1689 Write-Host "`n--- Collection Filtering ---" -ForegroundColor Magenta
1690 Write-Host "Agents after filter: $($chatAgents.Count)"
1691 Write-Host "Prompts after filter: $($chatPrompts.Count)"
1692 Write-Host "Instructions after filter: $($chatInstructions.Count)"
1693 Write-Host "Skills after filter: $($chatSkills.Count)"
1694 }
1695
1696 # Apply collection template when building a non-default collection
1697 if ($null -ne $collectionManifest -and $collectionManifest.id -ne 'hve-core') {
1698 $collectionId = $collectionManifest.id
1699 $templatePath = Join-Path $ExtensionDirectory "package.$collectionId.json"
1700 if (-not (Test-Path $templatePath)) {
1701 return New-PrepareResult -Success $false -ErrorMessage "Collection template not found: $templatePath"
1702 }
1703
1704 # Validate template consistency against collection manifest
1705 $consistency = Test-TemplateConsistency -TemplatePath $templatePath -CollectionManifest $collectionManifest
1706 if (-not $consistency.IsConsistent) {
1707 Write-Host "`n--- Template Consistency Warnings ---" -ForegroundColor Yellow
1708 foreach ($mismatch in $consistency.Mismatches) {
1709 Write-Warning "Template/manifest mismatch: $($mismatch.Message)"
1710 Write-CIAnnotation -Message "Template/manifest mismatch ($collectionId): $($mismatch.Message)" -Level Warning
1711 }
1712 }
1713
1714 # Back up canonical package.json for later restore
1715 $backupPath = Join-Path $ExtensionDirectory "package.json.bak"
1716 Copy-Item -Path $PackageJsonPath -Destination $backupPath -Force
1717
1718 # Copy collection template over package.json
1719 Copy-Item -Path $templatePath -Destination $PackageJsonPath -Force
1720
1721 # Re-read template as the working package.json
1722 $packageJson = Get-Content -Path $PackageJsonPath -Raw | ConvertFrom-Json
1723 Write-Host "Applied collection template: package.$collectionId.json" -ForegroundColor Green
1724 }
1725
1726 # Update package.json with generated contributes
1727 $packageJson = Update-PackageJsonContributes -PackageJson $packageJson `
1728 -ChatAgents $chatAgents `
1729 -ChatPromptFiles $chatPrompts `
1730 -ChatInstructions $chatInstructions `
1731 -ChatSkills $chatSkills
1732
1733 # Write updated package.json
1734 if (-not $DryRun) {
1735 $packageJson | ConvertTo-Json -Depth 10 | Set-Content -Path $PackageJsonPath -Encoding UTF8NoBOM
1736 Write-Host "`nUpdated package.json with discovered artifacts" -ForegroundColor Green
1737 }
1738 else {
1739 Write-Host "`n[DRY RUN] Would update package.json with discovered artifacts" -ForegroundColor Yellow
1740 }
1741
1742 # Handle changelog
1743 if ($ChangelogPath -and (Test-Path $ChangelogPath)) {
1744 $destChangelog = Join-Path $ExtensionDirectory "CHANGELOG.md"
1745 if (-not $DryRun) {
1746 Copy-Item -Path $ChangelogPath -Destination $destChangelog -Force
1747 Write-Host "Copied changelog to extension directory" -ForegroundColor Green
1748 }
1749 else {
1750 Write-Host "[DRY RUN] Would copy changelog to extension directory" -ForegroundColor Yellow
1751 }
1752 }
1753 elseif ($ChangelogPath) {
1754 Write-Warning "Changelog path specified but file not found: $ChangelogPath"
1755 }
1756
1757 Write-Host "`n=== Preparation Complete ===" -ForegroundColor Cyan
1758
1759 return New-PrepareResult -Success $true `
1760 -Version $version `
1761 -AgentCount $chatAgents.Count `
1762 -PromptCount $chatPrompts.Count `
1763 -InstructionCount $chatInstructions.Count `
1764 -SkillCount $chatSkills.Count
1765}
1766
1767#endregion Pure Functions
1768
1769#region Main Execution
1770if ($MyInvocation.InvocationName -ne '.') {
1771 try {
1772 # Verify PowerShell-Yaml module is available
1773 if (-not (Get-Module -ListAvailable -Name PowerShell-Yaml)) {
1774 throw "Required module 'PowerShell-Yaml' is not installed."
1775 }
1776 Import-Module PowerShell-Yaml -ErrorAction Stop
1777
1778 # Resolve paths using $MyInvocation (must stay in entry point)
1779 $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
1780 $RepoRoot = (Get-Item "$ScriptDir/../..").FullName
1781 $ExtensionDir = Join-Path $RepoRoot "extension"
1782
1783 # Resolve changelog path if provided
1784 $resolvedChangelogPath = ""
1785 if ($ChangelogPath) {
1786 $resolvedChangelogPath = if ([System.IO.Path]::IsPathRooted($ChangelogPath)) {
1787 $ChangelogPath
1788 }
1789 else {
1790 Join-Path $RepoRoot $ChangelogPath
1791 }
1792 }
1793
1794 # Default to hve-core collection when no collection is specified.
1795 # package.json is identity-mapped to the hve-core collection, so the
1796 # default build must apply hve-core filtering rather than including all
1797 # artifacts (hve-core-all behavior). Use -Collection with
1798 # hve-core-all.collection.yml explicitly to include everything.
1799 if (-not $Collection) {
1800 $Collection = Join-Path $RepoRoot 'collections/hve-core.collection.yml'
1801 }
1802
1803 Write-Host "📦 HVE Core Extension Preparer" -ForegroundColor Cyan
1804 Write-Host "==============================" -ForegroundColor Cyan
1805 Write-Host " Channel: $Channel" -ForegroundColor Cyan
1806 Write-Host " Collection: $Collection" -ForegroundColor Cyan
1807 Write-Host ""
1808
1809 # Call orchestration function
1810 $result = Invoke-PrepareExtension `
1811 -ExtensionDirectory $ExtensionDir `
1812 -RepoRoot $RepoRoot `
1813 -Channel $Channel `
1814 -ChangelogPath $resolvedChangelogPath `
1815 -DryRun:$DryRun `
1816 -Collection $Collection
1817
1818 if (-not $result.Success) {
1819 throw $result.ErrorMessage
1820 }
1821
1822 Write-Host ""
1823 Write-Host "🎉 Done!" -ForegroundColor Green
1824 Write-Host ""
1825 Write-Host "📊 Summary:" -ForegroundColor Cyan
1826 Write-Host " Agents: $($result.AgentCount)"
1827 Write-Host " Prompts: $($result.PromptCount)"
1828 Write-Host " Instructions: $($result.InstructionCount)"
1829 Write-Host " Skills: $($result.SkillCount)"
1830 Write-Host " Version: $($result.Version)"
1831
1832 exit 0
1833 }
1834 catch {
1835 Write-Error -ErrorAction Continue "Prepare-Extension failed: $($_.Exception.Message)"
1836 Write-CIAnnotation -Message $_.Exception.Message -Level Error
1837 exit 1
1838 }
1839}
1840#endregion Main Execution
1841