microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
chore/694-pr-skill-tests

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/plugins/Modules/PluginHelpers.psm1

1355lines · modecode

1# Copyright (c) Microsoft Corporation.
2# SPDX-License-Identifier: MIT
3
4# PluginHelpers.psm1
5#
6# Purpose: Shared functions for the Copilot CLI plugin generation pipeline.
7# Author: HVE Core Team
8
9#Requires -Version 7.0
10
11# ---------------------------------------------------------------------------
12# Pure Functions (no file system side effects)
13# ---------------------------------------------------------------------------
14
15function Test-DeprecatedPath {
16 <#
17 .SYNOPSIS
18 Checks whether a file path contains a deprecated directory segment.
19
20 .DESCRIPTION
21 Returns true when the path contains a /deprecated/ or \deprecated\ segment,
22 indicating the artifact resides in a deprecated directory tree.
23
24 .PARAMETER Path
25 File path to check (absolute or relative, any slash style).
26
27 .OUTPUTS
28 [bool] True when the path contains a deprecated segment.
29 #>
30 [CmdletBinding()]
31 [OutputType([bool])]
32 param(
33 [Parameter(Mandatory = $true)]
34 [ValidateNotNullOrEmpty()]
35 [string]$Path
36 )
37
38 return ($Path -match '[/\\]deprecated[/\\]')
39}
40
41function Test-HveCoreRepoSpecificPath {
42 <#
43 .SYNOPSIS
44 Checks whether a type-relative path is a root-level repo-specific artifact.
45
46 .DESCRIPTION
47 Returns true when the type-relative path has no subdirectory component,
48 indicating it is a root-level repo-specific artifact not intended for
49 distribution. Collection-scoped artifacts reside in subdirectories.
50
51 .PARAMETER RelativePath
52 Type-relative path (relative to the agents/, prompts/, instructions/, or skills/ directory).
53
54 .OUTPUTS
55 [bool] True when the path is repo-specific.
56 #>
57 [CmdletBinding()]
58 [OutputType([bool])]
59 param(
60 [Parameter(Mandatory = $true)]
61 [ValidateNotNullOrEmpty()]
62 [string]$RelativePath
63 )
64
65 return ($RelativePath -notlike '*/*')
66}
67
68function Test-HveCoreRepoRelativePath {
69 <#
70 .SYNOPSIS
71 Checks whether a repo-relative path is a root-level repo-specific artifact.
72
73 .DESCRIPTION
74 Returns true when the repo-relative path is directly under a .github type
75 directory (agents, instructions, prompts, skills) with no subdirectory,
76 indicating it is a root-level repo-specific artifact not intended for distribution.
77
78 .PARAMETER Path
79 Repo-relative path (e.g., .github/instructions/workflows.instructions.md).
80
81 .OUTPUTS
82 [bool] True when the path is a root-level repo-specific artifact.
83 #>
84 [CmdletBinding()]
85 [OutputType([bool])]
86 param(
87 [Parameter(Mandatory = $true)]
88 [ValidateNotNullOrEmpty()]
89 [string]$Path
90 )
91
92 return ($Path -match '^\.github/(agents|instructions|prompts|skills)/[^/]+$')
93}
94
95function Get-CollectionManifest {
96 <#
97 .SYNOPSIS
98 Loads a collection manifest from a YAML or JSON file.
99
100 .DESCRIPTION
101 Reads and parses a collection manifest file that defines collection-based
102 artifact filtering rules. Supports both YAML (.yml/.yaml) and JSON (.json)
103 formats.
104
105 .PARAMETER CollectionPath
106 Path to the collection manifest file (YAML or JSON).
107
108 .OUTPUTS
109 [hashtable] Parsed collection manifest with id, name, displayName, description, items, and optional include/exclude.
110 #>
111 [CmdletBinding()]
112 [OutputType([hashtable])]
113 param(
114 [Parameter(Mandatory = $true)]
115 [ValidateNotNullOrEmpty()]
116 [string]$CollectionPath
117 )
118
119 if (-not (Test-Path $CollectionPath)) {
120 throw "Collection manifest not found: $CollectionPath"
121 }
122
123 $extension = [System.IO.Path]::GetExtension($CollectionPath).ToLowerInvariant()
124 if ($extension -in @('.yml', '.yaml')) {
125 $content = Get-Content -Path $CollectionPath -Raw
126 return ConvertFrom-Yaml -Yaml $content
127 }
128
129 $content = Get-Content -Path $CollectionPath -Raw
130 return $content | ConvertFrom-Json -AsHashtable
131}
132
133function Get-CollectionArtifactKey {
134 <#
135 .SYNOPSIS
136 Extracts a unique key from an artifact path based on its kind.
137
138 .DESCRIPTION
139 Produces the same key that extension packaging uses for deduplication.
140 Agents and prompts use the filename only; instructions use the
141 type-relative path; skills use the directory name.
142
143 .PARAMETER Kind
144 The artifact kind (agent, prompt, instruction, skill).
145
146 .PARAMETER Path
147 The repo-relative artifact path.
148
149 .OUTPUTS
150 [string] The artifact key.
151 #>
152 [CmdletBinding()]
153 [OutputType([string])]
154 param(
155 [Parameter(Mandatory = $true)]
156 [string]$Kind,
157
158 [Parameter(Mandatory = $true)]
159 [string]$Path
160 )
161
162 switch ($Kind) {
163 'agent' {
164 return ([System.IO.Path]::GetFileName($Path) -replace '\.agent\.md$', '')
165 }
166 'prompt' {
167 return ([System.IO.Path]::GetFileName($Path) -replace '\.prompt\.md$', '')
168 }
169 'instruction' {
170 return ($Path -replace '^\.github/instructions/', '' -replace '\.instructions\.md$', '')
171 }
172 'skill' {
173 return [System.IO.Path]::GetFileName($Path.TrimEnd('/'))
174 }
175 default {
176 if ($Path -match "\.$([regex]::Escape($Kind))\.md$") {
177 return ([System.IO.Path]::GetFileName($Path) -replace "\.$([regex]::Escape($Kind))\.md$", '')
178 }
179
180 if ($Path -like '*.md') {
181 return [System.IO.Path]::GetFileNameWithoutExtension($Path)
182 }
183
184 return [System.IO.Path]::GetFileName($Path)
185 }
186 }
187}
188
189function Get-ArtifactFrontmatter {
190 <#
191 .SYNOPSIS
192 Extracts YAML frontmatter from a markdown file.
193
194 .DESCRIPTION
195 Parses the YAML frontmatter block delimited by --- markers at the start
196 of a markdown file. Returns a hashtable with description.
197
198 .PARAMETER FilePath
199 Path to the markdown file to parse.
200
201 .PARAMETER FallbackDescription
202 Default description if none found in frontmatter.
203
204 .OUTPUTS
205 [hashtable] With description key.
206 #>
207 [CmdletBinding()]
208 [OutputType([hashtable])]
209 param(
210 [Parameter(Mandatory = $true)]
211 [string]$FilePath,
212
213 [Parameter(Mandatory = $false)]
214 [string]$FallbackDescription = ''
215 )
216
217 $content = Get-Content -Path $FilePath -Raw
218 $description = ''
219
220 if ($content -match '(?s)^---\s*\r?\n(.*?)\r?\n---') {
221 $yamlContent = $Matches[1] -replace '\r\n', "`n" -replace '\r', "`n"
222 try {
223 $data = ConvertFrom-Yaml -Yaml $yamlContent
224 if ($data.ContainsKey('description')) {
225 $description = $data.description
226 }
227 }
228 catch {
229 Write-Warning "Failed to parse YAML frontmatter in $(Split-Path -Leaf $FilePath): $_"
230 }
231 }
232
233 return @{
234 description = if ($description) { $description } else { $FallbackDescription }
235 }
236}
237
238function Resolve-CollectionItemMaturity {
239 <#
240 .SYNOPSIS
241 Resolves effective maturity from collection item metadata.
242
243 .DESCRIPTION
244 Returns stable when maturity is omitted; otherwise returns the provided
245 maturity string.
246
247 .PARAMETER Maturity
248 Optional maturity value from a collection item.
249
250 .OUTPUTS
251 [string] Effective maturity value.
252 #>
253 [CmdletBinding()]
254 [OutputType([string])]
255 param(
256 [Parameter()]
257 [AllowNull()]
258 [AllowEmptyString()]
259 [string]$Maturity
260 )
261
262 if ([string]::IsNullOrWhiteSpace($Maturity)) {
263 return 'stable'
264 }
265
266 return $Maturity
267}
268
269function Get-AllCollections {
270 <#
271 .SYNOPSIS
272 Discovers and parses all .collection.yml files in a directory.
273
274 .DESCRIPTION
275 Scans the specified directory for files matching *.collection.yml and
276 parses each one into a hashtable via Get-CollectionManifest.
277
278 .PARAMETER CollectionsDir
279 Path to the directory containing .collection.yml files.
280
281 .OUTPUTS
282 [hashtable[]] Array of parsed collection manifests.
283 #>
284 [CmdletBinding()]
285 [OutputType([hashtable[]])]
286 param(
287 [Parameter(Mandatory = $true)]
288 [string]$CollectionsDir
289 )
290
291 $files = Get-ChildItem -Path $CollectionsDir -Filter '*.collection.yml' -File
292 $collections = @()
293
294 foreach ($file in $files) {
295 $manifest = Get-CollectionManifest -CollectionPath $file.FullName
296 $collections += $manifest
297 }
298
299 return $collections
300}
301
302function Get-ArtifactFiles {
303 <#
304 .SYNOPSIS
305 Discovers all artifact files from .github/ directories.
306
307 .DESCRIPTION
308 Scans .github/agents/, .github/prompts/, .github/instructions/ (recursively),
309 and .github/skills/ to build a complete list of collection items. Returns
310 repo-relative paths with forward slashes.
311
312 .PARAMETER RepoRoot
313 Absolute path to the repository root directory.
314
315 .OUTPUTS
316 [hashtable[]] Array of hashtables with path and kind keys.
317 #>
318 [CmdletBinding()]
319 [OutputType([hashtable[]])]
320 param(
321 [Parameter(Mandatory = $true)]
322 [ValidateNotNullOrEmpty()]
323 [string]$RepoRoot
324 )
325
326 $items = @()
327
328 # AI artifacts discovered by .<kind>.md suffix under .github/
329 # Keep explicit suffix mapping only where naming differs from manifest kind values.
330 $gitHubDir = Join-Path -Path $RepoRoot -ChildPath '.github'
331 if (Test-Path -Path $gitHubDir) {
332 $suffixToKind = @{
333 instructions = 'instruction'
334 }
335
336 $artifactFiles = Get-ChildItem -Path $gitHubDir -Filter '*.*.md' -File -Recurse
337 foreach ($file in $artifactFiles) {
338 if ($file.Name -notmatch '\.(?<suffix>[^.]+)\.md$') {
339 continue
340 }
341
342 $suffix = $Matches['suffix'].ToLowerInvariant()
343 $kind = if ($suffixToKind.ContainsKey($suffix)) { $suffixToKind[$suffix] } else { $suffix }
344 $relativePath = [System.IO.Path]::GetRelativePath($RepoRoot, $file.FullName) -replace '\\', '/'
345
346 if (Test-HveCoreRepoRelativePath -Path $relativePath) {
347 continue
348 }
349 if (Test-DeprecatedPath -Path $relativePath) {
350 continue
351 }
352 $items += @{ path = $relativePath; kind = $kind }
353 }
354 }
355
356 # Skills (directories containing SKILL.md)
357 $skillsDir = Join-Path -Path $RepoRoot -ChildPath '.github/skills'
358 if (Test-Path -Path $skillsDir) {
359 $skillMdFiles = Get-ChildItem -Path $skillsDir -Filter 'SKILL.md' -File -Recurse
360 foreach ($skillFile in $skillMdFiles) {
361 $dir = $skillFile.Directory
362 $relativePath = [System.IO.Path]::GetRelativePath($RepoRoot, $dir.FullName) -replace '\\', '/'
363
364 if (Test-DeprecatedPath -Path $relativePath) {
365 continue
366 }
367 if (Test-HveCoreRepoRelativePath -Path $relativePath) {
368 continue
369 }
370
371 $items += @{ path = $relativePath; kind = 'skill' }
372 }
373 }
374
375 return $items
376}
377
378function Test-ArtifactDeprecated {
379 <#
380 .SYNOPSIS
381 Checks whether an artifact has maturity deprecated in collection metadata.
382
383 .DESCRIPTION
384 Reads maturity from the provided collection item metadata value and
385 returns $true when the effective value equals deprecated.
386
387 .PARAMETER Maturity
388 Optional maturity value from collection item metadata.
389
390 .OUTPUTS
391 [bool] True when the artifact is deprecated.
392 #>
393 [CmdletBinding()]
394 [OutputType([bool])]
395 param(
396 [Parameter()]
397 [AllowNull()]
398 [AllowEmptyString()]
399 [string]$Maturity
400 )
401
402 return ((Resolve-CollectionItemMaturity -Maturity $Maturity) -eq 'deprecated')
403}
404
405function Update-HveCoreAllCollection {
406 <#
407 .SYNOPSIS
408 Auto-updates hve-core-all.collection.yml with all non-deprecated artifacts.
409
410 .DESCRIPTION
411 Discovers all artifacts from .github/ directories, excludes deprecated items,
412 and rewrites the hve-core-all collection manifest. Preserves existing
413 metadata fields (id, name, description, tags, display).
414
415 .PARAMETER RepoRoot
416 Absolute path to the repository root directory.
417
418 .PARAMETER DryRun
419 When specified, logs changes without writing to disk.
420
421 .OUTPUTS
422 [hashtable] With ItemCount, AddedCount, RemovedCount, and DeprecatedCount keys.
423 #>
424 [CmdletBinding()]
425 [OutputType([hashtable])]
426 param(
427 [Parameter(Mandatory = $true)]
428 [ValidateNotNullOrEmpty()]
429 [string]$RepoRoot,
430
431 [Parameter(Mandatory = $false)]
432 [switch]$DryRun
433 )
434
435 $collectionPath = Join-Path -Path $RepoRoot -ChildPath 'collections/hve-core-all.collection.yml'
436
437 # Read existing manifest to preserve metadata
438 $existing = Get-CollectionManifest -CollectionPath $collectionPath
439 $existingPaths = @($existing.items | ForEach-Object { $_.path })
440
441 # Discover all artifacts
442 $allItems = Get-ArtifactFiles -RepoRoot $RepoRoot
443
444 # Exclude deprecated items by path (independent of maturity metadata)
445 $allItems = @($allItems | Where-Object { -not (Test-DeprecatedPath -Path $_.path) })
446
447 # Filter deprecated based on existing collection item maturity metadata
448 $existingItemMaturities = @{}
449 foreach ($existingItem in $existing.items) {
450 $existingKey = "$($existingItem.kind)|$($existingItem.path)"
451 $existingItemMaturities[$existingKey] = Resolve-CollectionItemMaturity -Maturity $existingItem.maturity
452 }
453
454 $deprecatedCount = 0
455 $filteredItems = @()
456 foreach ($item in $allItems) {
457 $itemKey = "$($item.kind)|$($item.path)"
458 $itemMaturity = 'stable'
459 if ($existingItemMaturities.ContainsKey($itemKey)) {
460 $itemMaturity = $existingItemMaturities[$itemKey]
461 }
462
463 if (Test-ArtifactDeprecated -Maturity $itemMaturity) {
464 $deprecatedCount++
465 Write-Verbose "Excluding deprecated: $($item.path)"
466 continue
467 }
468
469 $filteredItems += @{
470 path = $item.path
471 kind = $item.kind
472 maturity = $itemMaturity
473 }
474 }
475
476 # Sort: known kinds first, then any additional kinds, then by path
477 $kindOrder = @{ 'agent' = 0; 'prompt' = 1; 'instruction' = 2; 'skill' = 3 }
478 $sortedItems = $filteredItems | Sort-Object `
479 { if ($kindOrder.ContainsKey($_.kind)) { $kindOrder[$_.kind] } else { 100 } }, `
480 { $_.kind }, `
481 { $_.path }
482
483 # Build new items array as ordered hashtables for clean YAML output
484 $newItems = @()
485 foreach ($item in $sortedItems) {
486 $newItem = [ordered]@{
487 path = $item.path
488 kind = $item.kind
489 }
490
491 if ((Resolve-CollectionItemMaturity -Maturity $item.maturity) -ne 'stable') {
492 $newItem['maturity'] = $item.maturity
493 }
494
495 $newItems += $newItem
496 }
497
498 # Compute diff
499 $newPaths = @($sortedItems | ForEach-Object { $_.path })
500 $added = @($newPaths | Where-Object { $_ -notin $existingPaths })
501 $removed = @($existingPaths | Where-Object { $_ -notin $newPaths })
502
503 Write-Host "`n--- hve-core-all Auto-Update ---" -ForegroundColor Cyan
504 Write-Host " Discovered: $($allItems.Count) artifacts"
505 Write-Host " Deprecated: $deprecatedCount (excluded)"
506 Write-Host " Final: $($newItems.Count) items"
507 if ($added.Count -gt 0) {
508 Write-Host " Added: $($added -join ', ')" -ForegroundColor Green
509 }
510 if ($removed.Count -gt 0) {
511 Write-Host " Removed: $($removed -join ', ')" -ForegroundColor Yellow
512 }
513
514 if ($DryRun) {
515 Write-Host ' [DRY RUN] No changes written' -ForegroundColor Yellow
516 }
517 else {
518 # Rebuild manifest preserving metadata
519 $manifest = [ordered]@{
520 id = $existing.id
521 name = $existing.name
522 description = $existing.description
523 tags = $existing.tags
524 items = $newItems
525 display = $existing.display
526 }
527
528 $yaml = ConvertTo-Yaml -Data $manifest
529 Set-Content -Path $collectionPath -Value $yaml -Encoding utf8 -NoNewline
530 Write-Verbose "Updated $collectionPath"
531 }
532
533 return @{
534 ItemCount = $newItems.Count
535 AddedCount = $added.Count
536 RemovedCount = $removed.Count
537 DeprecatedCount = $deprecatedCount
538 }
539}
540
541function Get-PluginItemName {
542 <#
543 .SYNOPSIS
544 Strips artifact-type suffix from a filename.
545
546 .DESCRIPTION
547 Removes the kind-specific suffix from a filename and returns the
548 simplified name with a .md extension (or the directory name for skills).
549
550 .PARAMETER FileName
551 The original filename (e.g. task-researcher.agent.md).
552
553 .PARAMETER Kind
554 The artifact kind: agent, prompt, instruction, or skill.
555
556 .OUTPUTS
557 [string] The simplified item name.
558 #>
559 [CmdletBinding()]
560 [OutputType([string])]
561 param(
562 [Parameter(Mandatory = $true)]
563 [string]$FileName,
564
565 [Parameter(Mandatory = $true)]
566 [ValidateSet('agent', 'prompt', 'instruction', 'skill')]
567 [string]$Kind
568 )
569
570 switch ($Kind) {
571 'agent' {
572 return ($FileName -replace '\.agent\.md$', '') + '.md'
573 }
574 'prompt' {
575 return ($FileName -replace '\.prompt\.md$', '') + '.md'
576 }
577 'instruction' {
578 return ($FileName -replace '\.instructions\.md$', '') + '.md'
579 }
580 'skill' {
581 return $FileName
582 }
583 }
584}
585
586function Get-PluginSubdirectory {
587 <#
588 .SYNOPSIS
589 Returns the plugin subdirectory name for an artifact kind.
590
591 .DESCRIPTION
592 Maps a collection item kind to the corresponding subdirectory name
593 within the plugin directory structure.
594
595 .PARAMETER Kind
596 The artifact kind: agent, prompt, instruction, or skill.
597
598 .OUTPUTS
599 [string] The subdirectory name (agents, commands, instructions, or skills).
600 #>
601 [CmdletBinding()]
602 [OutputType([string])]
603 param(
604 [Parameter(Mandatory = $true)]
605 [ValidateSet('agent', 'prompt', 'instruction', 'skill')]
606 [string]$Kind
607 )
608
609 switch ($Kind) {
610 'agent' { return 'agents' }
611 'prompt' { return 'commands' }
612 'instruction' { return 'instructions' }
613 'skill' { return 'skills' }
614 }
615}
616
617function New-PluginManifestContent {
618 <#
619 .SYNOPSIS
620 Generates plugin.json content as a hashtable.
621
622 .DESCRIPTION
623 Creates a hashtable representing the plugin manifest with name,
624 description, and version sourced from the repository package.json.
625
626 .PARAMETER CollectionId
627 The collection identifier used as the plugin name.
628
629 .PARAMETER Description
630 A short description of the plugin.
631
632 .PARAMETER Version
633 Semantic version string from the repository package.json.
634
635 .OUTPUTS
636 [hashtable] Plugin manifest with name, description, and version keys.
637 #>
638 [CmdletBinding()]
639 [OutputType([hashtable])]
640 param(
641 [Parameter(Mandatory = $true)]
642 [string]$CollectionId,
643
644 [Parameter(Mandatory = $true)]
645 [string]$Description,
646
647 [Parameter(Mandatory = $true)]
648 [string]$Version
649 )
650
651 return [ordered]@{
652 name = $CollectionId
653 description = $Description
654 version = $Version
655 }
656}
657
658function New-PluginReadmeContent {
659 <#
660 .SYNOPSIS
661 Generates README.md markdown for a plugin.
662
663 .DESCRIPTION
664 Builds a complete README.md string with a markdownlint-disable header,
665 title, description, install command, and tables for each artifact kind
666 that has items. Only sections with items are included.
667
668 .PARAMETER Collection
669 Hashtable with id, name, and description keys from the collection manifest.
670
671 .PARAMETER Items
672 Array of processed item objects. Each object must have Name, Description,
673 and Kind properties.
674
675 .PARAMETER Maturity
676 Optional collection-level maturity string. When 'experimental', an
677 experimental notice is injected after the description.
678
679 .OUTPUTS
680 [string] Complete README markdown content.
681 #>
682 [CmdletBinding()]
683 [OutputType([string])]
684 param(
685 [Parameter(Mandatory = $true)]
686 [hashtable]$Collection,
687
688 [Parameter(Mandatory = $true)]
689 [AllowEmptyCollection()]
690 [array]$Items,
691
692 [Parameter(Mandatory = $false)]
693 [AllowNull()]
694 [AllowEmptyString()]
695 [string]$Maturity
696 )
697
698 $sb = [System.Text.StringBuilder]::new()
699 [void]$sb.AppendLine('<!-- markdownlint-disable-file -->')
700 [void]$sb.AppendLine("# $($Collection.name)")
701 [void]$sb.AppendLine()
702 [void]$sb.AppendLine($Collection.description)
703
704 # Inject experimental notice when collection is experimental
705 $effectiveMaturity = if ([string]::IsNullOrWhiteSpace($Maturity)) { 'stable' } else { $Maturity }
706 if ($effectiveMaturity -eq 'experimental') {
707 [void]$sb.AppendLine()
708 [void]$sb.AppendLine("> **`u{26A0}`u{FE0F} Experimental** `u{2014} This collection is experimental. Contents and behavior may change or be removed without notice.")
709 }
710
711 [void]$sb.AppendLine()
712 [void]$sb.AppendLine('## Install')
713 [void]$sb.AppendLine()
714 [void]$sb.AppendLine('```bash')
715 [void]$sb.AppendLine("copilot plugin install $($Collection.id)@hve-core")
716 [void]$sb.AppendLine('```')
717
718 $sectionMap = [ordered]@{
719 agent = @{ Title = 'Agents'; Header = 'Agent' }
720 prompt = @{ Title = 'Commands'; Header = 'Command' }
721 instruction = @{ Title = 'Instructions'; Header = 'Instruction' }
722 skill = @{ Title = 'Skills'; Header = 'Skill' }
723 }
724
725 foreach ($entry in $sectionMap.GetEnumerator()) {
726 $kind = $entry.Key
727 $meta = $entry.Value
728 $kindItems = @($Items | Where-Object { $_.Kind -eq $kind })
729 if ($kindItems.Count -eq 0) {
730 continue
731 }
732
733 [void]$sb.AppendLine()
734 [void]$sb.AppendLine("## $($meta.Title)")
735 [void]$sb.AppendLine()
736 [void]$sb.AppendLine("| $($meta.Header) | Description |")
737 [void]$sb.AppendLine('| ' + ('-' * $meta.Header.Length) + ' | ----------- |')
738 foreach ($item in $kindItems) {
739 [void]$sb.AppendLine("| $($item.Name) | $($item.Description) |")
740 }
741 }
742
743 [void]$sb.AppendLine()
744 [void]$sb.AppendLine('---')
745 [void]$sb.AppendLine()
746 [void]$sb.AppendLine('> Source: [microsoft/hve-core](https://github.com/microsoft/hve-core)')
747 [void]$sb.AppendLine()
748
749 return $sb.ToString()
750}
751
752function New-MarketplaceManifestContent {
753 <#
754 .SYNOPSIS
755 Generates marketplace.json content as a hashtable.
756
757 .DESCRIPTION
758 Creates a hashtable representing the marketplace manifest with repository
759 metadata, owner information, and plugin entries. Matches the schema used
760 by github/awesome-copilot.
761
762 .PARAMETER RepoName
763 Repository name used as the marketplace name.
764
765 .PARAMETER Description
766 Short description of the repository.
767
768 .PARAMETER Version
769 Semantic version string from package.json.
770
771 .PARAMETER OwnerName
772 Organization or individual owning the repository.
773
774 .PARAMETER Plugins
775 Array of ordered hashtables with name, description, and version keys
776 from New-PluginManifestContent.
777
778 .OUTPUTS
779 [hashtable] Marketplace manifest with name, metadata, owner, and plugins keys.
780 #>
781 [CmdletBinding()]
782 [OutputType([hashtable])]
783 param(
784 [Parameter(Mandatory = $true)]
785 [string]$RepoName,
786
787 [Parameter(Mandatory = $true)]
788 [string]$Description,
789
790 [Parameter(Mandatory = $true)]
791 [string]$Version,
792
793 [Parameter(Mandatory = $true)]
794 [string]$OwnerName,
795
796 [Parameter(Mandatory = $true)]
797 [AllowEmptyCollection()]
798 [array]$Plugins
799 )
800
801 $pluginEntries = @()
802 foreach ($plugin in $Plugins) {
803 $pluginEntries += [ordered]@{
804 name = $plugin.name
805 source = "./plugins/$($plugin.name)"
806 description = $plugin.description
807 version = $plugin.version
808 }
809 }
810
811 return [ordered]@{
812 name = $RepoName
813 metadata = [ordered]@{
814 description = $Description
815 version = $Version
816 pluginRoot = './plugins'
817 }
818 owner = [ordered]@{
819 name = $OwnerName
820 }
821 plugins = $pluginEntries
822 }
823}
824
825function Write-MarketplaceManifest {
826 <#
827 .SYNOPSIS
828 Writes the marketplace.json file to .github/plugin/.
829
830 .DESCRIPTION
831 Assembles plugin metadata from generated collections and writes the
832 marketplace manifest to .github/plugin/marketplace.json. Creates the
833 directory when it does not exist.
834
835 .PARAMETER RepoRoot
836 Absolute path to the repository root directory.
837
838 .PARAMETER Collections
839 Array of collection manifest hashtables with id and description.
840
841 .PARAMETER DryRun
842 When specified, logs the action without writing to disk.
843 #>
844 [CmdletBinding()]
845 param(
846 [Parameter(Mandatory = $true)]
847 [ValidateNotNullOrEmpty()]
848 [string]$RepoRoot,
849
850 [Parameter(Mandatory = $true)]
851 [AllowEmptyCollection()]
852 [array]$Collections,
853
854 [Parameter(Mandatory = $false)]
855 [switch]$DryRun
856 )
857
858 $packageJsonPath = Join-Path -Path $RepoRoot -ChildPath 'package.json'
859 $packageJson = Get-Content -Path $packageJsonPath -Raw | ConvertFrom-Json
860
861 $plugins = @()
862 foreach ($collection in ($Collections | Sort-Object { $_.id })) {
863 $plugins += New-PluginManifestContent `
864 -CollectionId $collection.id `
865 -Description $collection.description `
866 -Version $packageJson.version
867 }
868
869 $manifest = New-MarketplaceManifestContent `
870 -RepoName $packageJson.name `
871 -Description $packageJson.description `
872 -Version $packageJson.version `
873 -OwnerName $packageJson.author `
874 -Plugins $plugins
875
876 $outputDir = Join-Path -Path $RepoRoot -ChildPath '.github' -AdditionalChildPath 'plugin'
877 $outputPath = Join-Path -Path $outputDir -ChildPath 'marketplace.json'
878
879 if ($DryRun) {
880 Write-Host " [DRY RUN] Would write marketplace.json at $outputPath" -ForegroundColor Yellow
881 return
882 }
883
884 if (-not (Test-Path -Path $outputDir)) {
885 New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
886 }
887
888 $manifest | ConvertTo-Json -Depth 10 | Set-Content -Path $outputPath -Encoding utf8 -NoNewline
889 Write-Host " Marketplace manifest: $outputPath" -ForegroundColor Green
890}
891
892function New-GenerateResult {
893 <#
894 .SYNOPSIS
895 Creates a standardized result object.
896
897 .DESCRIPTION
898 Returns a hashtable representing the outcome of a plugin generation run
899 with success status, plugin count, and optional error message.
900
901 .PARAMETER Success
902 Whether the operation succeeded.
903
904 .PARAMETER PluginCount
905 Number of plugins generated.
906
907 .PARAMETER ErrorMessage
908 Optional error message when Success is $false.
909
910 .OUTPUTS
911 [hashtable] Result with Success, PluginCount, and ErrorMessage keys.
912 #>
913 [CmdletBinding()]
914 [OutputType([hashtable])]
915 param(
916 [Parameter(Mandatory = $true)]
917 [bool]$Success,
918
919 [Parameter(Mandatory = $true)]
920 [int]$PluginCount,
921
922 [Parameter(Mandatory = $false)]
923 [string]$ErrorMessage = ''
924 )
925
926 return @{
927 Success = $Success
928 PluginCount = $PluginCount
929 ErrorMessage = $ErrorMessage
930 }
931}
932
933# ---------------------------------------------------------------------------
934# I/O Functions (file system operations)
935# ---------------------------------------------------------------------------
936
937function Test-SymlinkCapability {
938 <#
939 .SYNOPSIS
940 Probes whether the current process can create symbolic links.
941
942 .DESCRIPTION
943 Creates a temporary file and attempts to symlink to it. Returns $true
944 when the OS and process privileges allow symlink creation, $false
945 otherwise. The probe directory is cleaned up unconditionally.
946 #>
947 [CmdletBinding()]
948 [OutputType([bool])]
949 param()
950
951 $tempDir = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "hve-symlink-probe-$PID"
952 $targetFile = Join-Path -Path $tempDir -ChildPath 'target.txt'
953 $linkFile = Join-Path -Path $tempDir -ChildPath 'link.txt'
954 try {
955 New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
956 Set-Content -Path $targetFile -Value 'probe' -NoNewline
957 New-Item -ItemType SymbolicLink -Path $linkFile -Target $targetFile -ErrorAction Stop | Out-Null
958 return $true
959 }
960 catch {
961 return $false
962 }
963 finally {
964 if (Test-Path -Path $tempDir) {
965 Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
966 }
967 }
968}
969
970function New-PluginLink {
971 <#
972 .SYNOPSIS
973 Links a source path into a plugin destination via symlink or text stub.
974
975 .DESCRIPTION
976 When SymlinkCapable is set, creates a relative symbolic link from
977 DestinationPath to SourcePath. Otherwise writes a text stub file
978 containing the relative path, matching the format git produces when
979 core.symlinks is false. Text stubs keep git status clean on Windows
980 without Developer Mode or elevated privileges.
981
982 .PARAMETER SourcePath
983 Absolute path to the real file or directory.
984
985 .PARAMETER DestinationPath
986 Absolute path where the link or text stub will be created.
987
988 .PARAMETER SymlinkCapable
989 When set, create a symbolic link; otherwise write a text stub.
990 #>
991 [CmdletBinding()]
992 param(
993 [Parameter(Mandatory = $true)]
994 [string]$SourcePath,
995
996 [Parameter(Mandatory = $true)]
997 [string]$DestinationPath,
998
999 [Parameter(Mandatory = $false)]
1000 [switch]$SymlinkCapable
1001 )
1002
1003 $destinationDir = Split-Path -Parent $DestinationPath
1004 if (-not (Test-Path -Path $destinationDir)) {
1005 New-Item -ItemType Directory -Path $destinationDir -Force | Out-Null
1006 }
1007
1008 $relativePath = [System.IO.Path]::GetRelativePath($destinationDir, $SourcePath) -replace '\\', '/'
1009
1010 if ($SymlinkCapable) {
1011 New-Item -ItemType SymbolicLink -Path $DestinationPath -Value $relativePath -Force | Out-Null
1012 }
1013 else {
1014 [System.IO.File]::WriteAllText($DestinationPath, $relativePath)
1015 }
1016}
1017
1018function Write-PluginDirectory {
1019 <#
1020 .SYNOPSIS
1021 Creates a complete plugin directory structure from a collection.
1022
1023 .DESCRIPTION
1024 Builds the full plugin layout under the specified plugins directory,
1025 including subdirectories for agents, commands, instructions, and skills.
1026 Each item is linked or copied from the plugin directory back to its
1027 source in the repository. Generates plugin.json and README.md.
1028
1029 .PARAMETER Collection
1030 Parsed collection manifest hashtable with id, name, description, and items.
1031
1032 .PARAMETER PluginsDir
1033 Absolute path to the root plugins output directory.
1034
1035 .PARAMETER RepoRoot
1036 Absolute path to the repository root.
1037
1038 .PARAMETER Version
1039 Semantic version string from the repository package.json.
1040
1041 .PARAMETER Maturity
1042 Optional collection-level maturity string. Forwarded to
1043 New-PluginReadmeContent for experimental notice injection.
1044
1045 .PARAMETER DryRun
1046 When specified, logs actions without creating files or directories.
1047
1048 .PARAMETER SymlinkCapable
1049 When specified, creates symbolic links; otherwise copies files.
1050
1051 .OUTPUTS
1052 [hashtable] Result with Success, AgentCount, CommandCount, InstructionCount,
1053 and SkillCount keys.
1054 #>
1055 [CmdletBinding()]
1056 [OutputType([hashtable])]
1057 param(
1058 [Parameter(Mandatory = $true)]
1059 [hashtable]$Collection,
1060
1061 [Parameter(Mandatory = $true)]
1062 [string]$PluginsDir,
1063
1064 [Parameter(Mandatory = $true)]
1065 [string]$RepoRoot,
1066
1067 [Parameter(Mandatory = $true)]
1068 [string]$Version,
1069
1070 [Parameter(Mandatory = $false)]
1071 [AllowNull()]
1072 [AllowEmptyString()]
1073 [string]$Maturity,
1074
1075 [Parameter(Mandatory = $false)]
1076 [switch]$DryRun,
1077
1078 [Parameter(Mandatory = $false)]
1079 [switch]$SymlinkCapable
1080 )
1081
1082 $collectionId = $Collection.id
1083 $pluginRoot = Join-Path -Path $PluginsDir -ChildPath $collectionId
1084
1085 $counts = @{
1086 AgentCount = 0
1087 CommandCount = 0
1088 InstructionCount = 0
1089 SkillCount = 0
1090 }
1091
1092 $readmeItems = @()
1093
1094 foreach ($item in $Collection.items) {
1095 $kind = $item.kind
1096 $sourcePath = Join-Path -Path $RepoRoot -ChildPath $item.path
1097 $subdir = Get-PluginSubdirectory -Kind $kind
1098
1099 if ($kind -eq 'skill') {
1100 # Skills are directory symlinks; use the directory name as FileName
1101 $fileName = Split-Path -Leaf $item.path
1102 $itemName = Get-PluginItemName -FileName $fileName -Kind $kind
1103 $destPath = Join-Path -Path $pluginRoot -ChildPath $subdir -AdditionalChildPath $itemName
1104 $description = $fileName
1105 }
1106 else {
1107 $fileName = Split-Path -Leaf $item.path
1108 $itemName = Get-PluginItemName -FileName $fileName -Kind $kind
1109 $destPath = Join-Path -Path $pluginRoot -ChildPath $subdir -AdditionalChildPath $itemName
1110
1111 # Read frontmatter from the source file for description
1112 $fallback = $itemName -replace '\.md$', ''
1113 if (Test-Path -Path $sourcePath) {
1114 $frontmatter = Get-ArtifactFrontmatter -FilePath $sourcePath -FallbackDescription $fallback
1115 $description = $frontmatter.description
1116 }
1117 else {
1118 $description = $fallback
1119 Write-Warning "Source file not found: $sourcePath"
1120 }
1121 }
1122
1123 $readmeItems += @{
1124 Name = $itemName -replace '\.md$', ''
1125 Description = $description
1126 Kind = $kind
1127 }
1128
1129 # Update counts
1130 switch ($kind) {
1131 'agent' { $counts.AgentCount++ }
1132 'prompt' { $counts.CommandCount++ }
1133 'instruction' { $counts.InstructionCount++ }
1134 'skill' { $counts.SkillCount++ }
1135 }
1136
1137 if ($DryRun) {
1138 Write-Verbose "DryRun: Would create link $destPath -> $sourcePath"
1139 continue
1140 }
1141
1142 New-PluginLink -SourcePath $sourcePath -DestinationPath $destPath -SymlinkCapable:$SymlinkCapable
1143 }
1144
1145 # Link shared resource directories (unconditional, all plugins)
1146 $sharedDirs = @(
1147 @{ Source = 'docs/templates'; Destination = 'docs/templates' }
1148 @{ Source = 'scripts/lib'; Destination = 'scripts/lib' }
1149 )
1150
1151 foreach ($dir in $sharedDirs) {
1152 $sourcePath = Join-Path -Path $RepoRoot -ChildPath $dir.Source
1153 $destPath = Join-Path -Path $pluginRoot -ChildPath $dir.Destination
1154
1155 if (-not (Test-Path -Path $sourcePath)) {
1156 Write-Warning "Shared directory not found: $sourcePath"
1157 continue
1158 }
1159
1160 if ($DryRun) {
1161 Write-Verbose "DryRun: Would create shared directory link $destPath -> $sourcePath"
1162 continue
1163 }
1164
1165 New-PluginLink -SourcePath $sourcePath -DestinationPath $destPath -SymlinkCapable:$SymlinkCapable
1166 }
1167
1168 # Generate plugin.json
1169 $manifestDir = Join-Path -Path $pluginRoot -ChildPath '.github' -AdditionalChildPath 'plugin'
1170 $manifestPath = Join-Path -Path $manifestDir -ChildPath 'plugin.json'
1171 $manifest = New-PluginManifestContent -CollectionId $collectionId -Description $Collection.description -Version $Version
1172
1173 if ($DryRun) {
1174 Write-Verbose "DryRun: Would write plugin.json at $manifestPath"
1175 }
1176 else {
1177 if (-not (Test-Path -Path $manifestDir)) {
1178 New-Item -ItemType Directory -Path $manifestDir -Force | Out-Null
1179 }
1180 $manifest | ConvertTo-Json -Depth 10 | Set-Content -Path $manifestPath -Encoding utf8 -NoNewline
1181 }
1182
1183 # Generate README.md
1184 $readmePath = Join-Path -Path $pluginRoot -ChildPath 'README.md'
1185 $readmeContent = New-PluginReadmeContent -Collection $Collection -Items $readmeItems -Maturity $Maturity
1186
1187 if ($DryRun) {
1188 Write-Verbose "DryRun: Would write README.md at $readmePath"
1189 }
1190 else {
1191 Set-Content -Path $readmePath -Value $readmeContent -Encoding utf8 -NoNewline
1192 }
1193
1194 return @{
1195 Success = $true
1196 AgentCount = $counts.AgentCount
1197 CommandCount = $counts.CommandCount
1198 InstructionCount = $counts.InstructionCount
1199 SkillCount = $counts.SkillCount
1200 }
1201}
1202
1203function Repair-PluginSymlinkIndex {
1204 <#
1205 .SYNOPSIS
1206 Fixes git index modes for text stub files so they register as symlinks.
1207
1208 .DESCRIPTION
1209 On systems where symlinks are unavailable (Windows without Developer Mode),
1210 New-PluginLink writes text stubs containing relative paths. Git stages
1211 these as mode 100644 (regular file). This function re-indexes each text
1212 stub as mode 120000 (symlink) so that Linux/macOS checkouts materialize
1213 real symbolic links.
1214
1215 .PARAMETER PluginsDir
1216 Absolute path to the plugins output directory.
1217
1218 .PARAMETER RepoRoot
1219 Absolute path to the repository root (git working tree).
1220
1221 .PARAMETER DryRun
1222 When specified, logs what would be fixed without modifying the index.
1223
1224 .OUTPUTS
1225 [int] Number of index entries corrected.
1226 #>
1227 [CmdletBinding()]
1228 [OutputType([int])]
1229 param(
1230 [Parameter(Mandatory = $true)]
1231 [ValidateNotNullOrEmpty()]
1232 [string]$PluginsDir,
1233
1234 [Parameter(Mandatory = $true)]
1235 [ValidateNotNullOrEmpty()]
1236 [string]$RepoRoot,
1237
1238 [Parameter(Mandatory = $false)]
1239 [switch]$DryRun
1240 )
1241
1242 if (-not (Test-Path -Path $PluginsDir)) {
1243 return 0
1244 }
1245
1246 # Build a set of paths already tracked in the git index under plugins/.
1247 # --index-info silently ignores untracked paths (PowerShell pipe encoding
1248 # issue), so new files must be added individually via --cacheinfo.
1249 $trackedPaths = [System.Collections.Generic.HashSet[string]]::new(
1250 [System.StringComparer]::OrdinalIgnoreCase
1251 )
1252 $pluginsRel = [System.IO.Path]::GetRelativePath($RepoRoot, $PluginsDir) -replace '\\', '/'
1253 $lsOutput = git ls-files -- $pluginsRel 2>$null
1254 if ($lsOutput) {
1255 foreach ($p in @($lsOutput)) { [void]$trackedPaths.Add($p) }
1256 }
1257
1258 $fixedCount = 0
1259 $newEntries = [System.Collections.Generic.List[PSCustomObject]]::new()
1260 $batchEntries = [System.Collections.Generic.List[string]]::new()
1261 $files = Get-ChildItem -Path $PluginsDir -File -Recurse
1262
1263 foreach ($file in $files) {
1264 # Text stubs are small files whose content is a relative path with
1265 # forward slashes, no line breaks, starting with ../
1266 if ($file.Length -gt 500) {
1267 continue
1268 }
1269
1270 $content = [System.IO.File]::ReadAllText($file.FullName)
1271
1272 if ($content -notmatch '^\.\./') {
1273 continue
1274 }
1275 if ($content.Contains("`n") -or $content.Contains("`r")) {
1276 continue
1277 }
1278
1279 $repoRelPath = [System.IO.Path]::GetRelativePath($RepoRoot, $file.FullName) -replace '\\', '/'
1280
1281 if ($DryRun) {
1282 Write-Verbose "DryRun: Would fix index mode for $repoRelPath"
1283 $fixedCount++
1284 continue
1285 }
1286
1287 $hashOutput = git hash-object -w -- $file.FullName 2>&1
1288 if ($LASTEXITCODE -ne 0) {
1289 Write-Warning "Failed to hash-object for $repoRelPath"
1290 continue
1291 }
1292
1293 # Extract clean SHA string, filtering out any ErrorRecord objects
1294 $sha = @($hashOutput | Where-Object { $_ -is [string] -and $_ -match '^[0-9a-f]{40}' })[0]
1295 if (-not $sha) {
1296 Write-Warning "No valid SHA returned for $repoRelPath"
1297 continue
1298 }
1299
1300 if ($trackedPaths.Contains($repoRelPath)) {
1301 $batchEntries.Add("120000 $sha`t$repoRelPath")
1302 } else {
1303 $newEntries.Add([PSCustomObject]@{ Sha = $sha; Path = $repoRelPath })
1304 }
1305 $fixedCount++
1306 Write-Verbose "Queued index fix: $repoRelPath -> 120000"
1307 }
1308
1309 # Add new/untracked files individually (typically few per run)
1310 foreach ($entry in $newEntries) {
1311 $cacheResult = git update-index --add --cacheinfo "120000,$($entry.Sha),$($entry.Path)" 2>&1
1312 if ($LASTEXITCODE -ne 0) {
1313 $errorMsg = @($cacheResult | ForEach-Object { $_.ToString() }) -join '; '
1314 Write-Warning "Failed to add index entry for $($entry.Path): $errorMsg"
1315 $fixedCount--
1316 }
1317 }
1318
1319 # Batch update existing entries in a single call to avoid index.lock contention
1320 if ($batchEntries.Count -gt 0) {
1321 $indexResult = $batchEntries | git update-index --index-info 2>&1
1322 if ($LASTEXITCODE -ne 0) {
1323 $errorMsg = @($indexResult | ForEach-Object { $_.ToString() }) -join '; '
1324 Write-Warning "Failed to update git index: $errorMsg"
1325 return 0
1326 }
1327 }
1328
1329 return $fixedCount
1330}
1331
1332Export-ModuleMember -Function @(
1333 'Get-AllCollections',
1334 'Get-ArtifactFiles',
1335 'Get-ArtifactFrontmatter',
1336 'Get-CollectionArtifactKey',
1337 'Get-CollectionManifest',
1338 'Get-PluginItemName',
1339 'Get-PluginSubdirectory',
1340 'New-GenerateResult',
1341 'New-MarketplaceManifestContent',
1342 'New-PluginManifestContent',
1343 'New-PluginReadmeContent',
1344 'New-PluginLink',
1345 'Repair-PluginSymlinkIndex',
1346 'Test-SymlinkCapability',
1347 'Resolve-CollectionItemMaturity',
1348 'Test-ArtifactDeprecated',
1349 'Test-DeprecatedPath',
1350 'Test-HveCoreRepoRelativePath',
1351 'Test-HveCoreRepoSpecificPath',
1352 'Update-HveCoreAllCollection',
1353 'Write-MarketplaceManifest',
1354 'Write-PluginDirectory'
1355)
1356