microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
docs/621-ai-artifacts

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/extension/Prepare-Extension.ps1

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