microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
copilot/update-workflow-file-and-script

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/plugins/Modules/PluginHelpers.psm1

1362lines · 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 $displayOrdered = [ordered]@{}
520 if ($existing.display.Contains('featured')) {
521 $displayOrdered['featured'] = $existing.display['featured']
522 }
523 if ($existing.display.Contains('ordering')) {
524 $displayOrdered['ordering'] = $existing.display['ordering']
525 }
526 $manifest = [ordered]@{
527 id = $existing.id
528 name = $existing.name
529 description = $existing.description
530 tags = $existing.tags
531 items = $newItems
532 display = $displayOrdered
533 }
534
535 $yaml = ConvertTo-Yaml -Data $manifest
536 Set-Content -Path $collectionPath -Value $yaml -Encoding utf8 -NoNewline
537 Write-Verbose "Updated $collectionPath"
538 }
539
540 return @{
541 ItemCount = $newItems.Count
542 AddedCount = $added.Count
543 RemovedCount = $removed.Count
544 DeprecatedCount = $deprecatedCount
545 }
546}
547
548function Get-PluginItemName {
549 <#
550 .SYNOPSIS
551 Strips artifact-type suffix from a filename.
552
553 .DESCRIPTION
554 Removes the kind-specific suffix from a filename and returns the
555 simplified name with a .md extension (or the directory name for skills).
556
557 .PARAMETER FileName
558 The original filename (e.g. task-researcher.agent.md).
559
560 .PARAMETER Kind
561 The artifact kind: agent, prompt, instruction, or skill.
562
563 .OUTPUTS
564 [string] The simplified item name.
565 #>
566 [CmdletBinding()]
567 [OutputType([string])]
568 param(
569 [Parameter(Mandatory = $true)]
570 [string]$FileName,
571
572 [Parameter(Mandatory = $true)]
573 [ValidateSet('agent', 'prompt', 'instruction', 'skill')]
574 [string]$Kind
575 )
576
577 switch ($Kind) {
578 'agent' {
579 return ($FileName -replace '\.agent\.md$', '') + '.md'
580 }
581 'prompt' {
582 return ($FileName -replace '\.prompt\.md$', '') + '.md'
583 }
584 'instruction' {
585 return ($FileName -replace '\.instructions\.md$', '') + '.md'
586 }
587 'skill' {
588 return $FileName
589 }
590 }
591}
592
593function Get-PluginSubdirectory {
594 <#
595 .SYNOPSIS
596 Returns the plugin subdirectory name for an artifact kind.
597
598 .DESCRIPTION
599 Maps a collection item kind to the corresponding subdirectory name
600 within the plugin directory structure.
601
602 .PARAMETER Kind
603 The artifact kind: agent, prompt, instruction, or skill.
604
605 .OUTPUTS
606 [string] The subdirectory name (agents, commands, instructions, or skills).
607 #>
608 [CmdletBinding()]
609 [OutputType([string])]
610 param(
611 [Parameter(Mandatory = $true)]
612 [ValidateSet('agent', 'prompt', 'instruction', 'skill')]
613 [string]$Kind
614 )
615
616 switch ($Kind) {
617 'agent' { return 'agents' }
618 'prompt' { return 'commands' }
619 'instruction' { return 'instructions' }
620 'skill' { return 'skills' }
621 }
622}
623
624function New-PluginManifestContent {
625 <#
626 .SYNOPSIS
627 Generates plugin.json content as a hashtable.
628
629 .DESCRIPTION
630 Creates a hashtable representing the plugin manifest with name,
631 description, and version sourced from the repository package.json.
632
633 .PARAMETER CollectionId
634 The collection identifier used as the plugin name.
635
636 .PARAMETER Description
637 A short description of the plugin.
638
639 .PARAMETER Version
640 Semantic version string from the repository package.json.
641
642 .OUTPUTS
643 [hashtable] Plugin manifest with name, description, and version keys.
644 #>
645 [CmdletBinding()]
646 [OutputType([hashtable])]
647 param(
648 [Parameter(Mandatory = $true)]
649 [string]$CollectionId,
650
651 [Parameter(Mandatory = $true)]
652 [string]$Description,
653
654 [Parameter(Mandatory = $true)]
655 [string]$Version
656 )
657
658 return [ordered]@{
659 name = $CollectionId
660 description = $Description
661 version = $Version
662 }
663}
664
665function New-PluginReadmeContent {
666 <#
667 .SYNOPSIS
668 Generates README.md markdown for a plugin.
669
670 .DESCRIPTION
671 Builds a complete README.md string with a markdownlint-disable header,
672 title, description, install command, and tables for each artifact kind
673 that has items. Only sections with items are included.
674
675 .PARAMETER Collection
676 Hashtable with id, name, and description keys from the collection manifest.
677
678 .PARAMETER Items
679 Array of processed item objects. Each object must have Name, Description,
680 and Kind properties.
681
682 .PARAMETER Maturity
683 Optional collection-level maturity string. When 'experimental', an
684 experimental notice is injected after the description.
685
686 .OUTPUTS
687 [string] Complete README markdown content.
688 #>
689 [CmdletBinding()]
690 [OutputType([string])]
691 param(
692 [Parameter(Mandatory = $true)]
693 [hashtable]$Collection,
694
695 [Parameter(Mandatory = $true)]
696 [AllowEmptyCollection()]
697 [array]$Items,
698
699 [Parameter(Mandatory = $false)]
700 [AllowNull()]
701 [AllowEmptyString()]
702 [string]$Maturity
703 )
704
705 $sb = [System.Text.StringBuilder]::new()
706 [void]$sb.AppendLine('<!-- markdownlint-disable-file -->')
707 [void]$sb.AppendLine("# $($Collection.name)")
708 [void]$sb.AppendLine()
709 [void]$sb.AppendLine($Collection.description)
710
711 # Inject experimental notice when collection is experimental
712 $effectiveMaturity = if ([string]::IsNullOrWhiteSpace($Maturity)) { 'stable' } else { $Maturity }
713 if ($effectiveMaturity -eq 'experimental') {
714 [void]$sb.AppendLine()
715 [void]$sb.AppendLine("> **`u{26A0}`u{FE0F} Experimental** `u{2014} This collection is experimental. Contents and behavior may change or be removed without notice.")
716 }
717
718 [void]$sb.AppendLine()
719 [void]$sb.AppendLine('## Install')
720 [void]$sb.AppendLine()
721 [void]$sb.AppendLine('```bash')
722 [void]$sb.AppendLine("copilot plugin install $($Collection.id)@hve-core")
723 [void]$sb.AppendLine('```')
724
725 $sectionMap = [ordered]@{
726 agent = @{ Title = 'Agents'; Header = 'Agent' }
727 prompt = @{ Title = 'Commands'; Header = 'Command' }
728 instruction = @{ Title = 'Instructions'; Header = 'Instruction' }
729 skill = @{ Title = 'Skills'; Header = 'Skill' }
730 }
731
732 foreach ($entry in $sectionMap.GetEnumerator()) {
733 $kind = $entry.Key
734 $meta = $entry.Value
735 $kindItems = @($Items | Where-Object { $_.Kind -eq $kind })
736 if ($kindItems.Count -eq 0) {
737 continue
738 }
739
740 [void]$sb.AppendLine()
741 [void]$sb.AppendLine("## $($meta.Title)")
742 [void]$sb.AppendLine()
743 [void]$sb.AppendLine("| $($meta.Header) | Description |")
744 [void]$sb.AppendLine('| ' + ('-' * $meta.Header.Length) + ' | ----------- |')
745 foreach ($item in $kindItems) {
746 [void]$sb.AppendLine("| $($item.Name) | $($item.Description) |")
747 }
748 }
749
750 [void]$sb.AppendLine()
751 [void]$sb.AppendLine('---')
752 [void]$sb.AppendLine()
753 [void]$sb.AppendLine('> Source: [microsoft/hve-core](https://github.com/microsoft/hve-core)')
754 [void]$sb.AppendLine()
755
756 return $sb.ToString()
757}
758
759function New-MarketplaceManifestContent {
760 <#
761 .SYNOPSIS
762 Generates marketplace.json content as a hashtable.
763
764 .DESCRIPTION
765 Creates a hashtable representing the marketplace manifest with repository
766 metadata, owner information, and plugin entries. Matches the schema used
767 by github/awesome-copilot.
768
769 .PARAMETER RepoName
770 Repository name used as the marketplace name.
771
772 .PARAMETER Description
773 Short description of the repository.
774
775 .PARAMETER Version
776 Semantic version string from package.json.
777
778 .PARAMETER OwnerName
779 Organization or individual owning the repository.
780
781 .PARAMETER Plugins
782 Array of ordered hashtables with name, description, and version keys
783 from New-PluginManifestContent.
784
785 .OUTPUTS
786 [hashtable] Marketplace manifest with name, metadata, owner, and plugins keys.
787 #>
788 [CmdletBinding()]
789 [OutputType([hashtable])]
790 param(
791 [Parameter(Mandatory = $true)]
792 [string]$RepoName,
793
794 [Parameter(Mandatory = $true)]
795 [string]$Description,
796
797 [Parameter(Mandatory = $true)]
798 [string]$Version,
799
800 [Parameter(Mandatory = $true)]
801 [string]$OwnerName,
802
803 [Parameter(Mandatory = $true)]
804 [AllowEmptyCollection()]
805 [array]$Plugins
806 )
807
808 $pluginEntries = @()
809 foreach ($plugin in $Plugins) {
810 $pluginEntries += [ordered]@{
811 name = $plugin.name
812 source = $plugin.name
813 description = $plugin.description
814 version = $plugin.version
815 }
816 }
817
818 return [ordered]@{
819 name = $RepoName
820 metadata = [ordered]@{
821 description = $Description
822 version = $Version
823 pluginRoot = './plugins'
824 }
825 owner = [ordered]@{
826 name = $OwnerName
827 }
828 plugins = $pluginEntries
829 }
830}
831
832function Write-MarketplaceManifest {
833 <#
834 .SYNOPSIS
835 Writes the marketplace.json file to .github/plugin/.
836
837 .DESCRIPTION
838 Assembles plugin metadata from generated collections and writes the
839 marketplace manifest to .github/plugin/marketplace.json. Creates the
840 directory when it does not exist.
841
842 .PARAMETER RepoRoot
843 Absolute path to the repository root directory.
844
845 .PARAMETER Collections
846 Array of collection manifest hashtables with id and description.
847
848 .PARAMETER DryRun
849 When specified, logs the action without writing to disk.
850 #>
851 [CmdletBinding()]
852 param(
853 [Parameter(Mandatory = $true)]
854 [ValidateNotNullOrEmpty()]
855 [string]$RepoRoot,
856
857 [Parameter(Mandatory = $true)]
858 [AllowEmptyCollection()]
859 [array]$Collections,
860
861 [Parameter(Mandatory = $false)]
862 [switch]$DryRun
863 )
864
865 $packageJsonPath = Join-Path -Path $RepoRoot -ChildPath 'package.json'
866 $packageJson = Get-Content -Path $packageJsonPath -Raw | ConvertFrom-Json
867
868 $plugins = @()
869 foreach ($collection in ($Collections | Sort-Object { $_.id })) {
870 $plugins += New-PluginManifestContent `
871 -CollectionId $collection.id `
872 -Description $collection.description `
873 -Version $packageJson.version
874 }
875
876 $manifest = New-MarketplaceManifestContent `
877 -RepoName $packageJson.name `
878 -Description $packageJson.description `
879 -Version $packageJson.version `
880 -OwnerName $packageJson.author `
881 -Plugins $plugins
882
883 $outputDir = Join-Path -Path $RepoRoot -ChildPath '.github' -AdditionalChildPath 'plugin'
884 $outputPath = Join-Path -Path $outputDir -ChildPath 'marketplace.json'
885
886 if ($DryRun) {
887 Write-Host " [DRY RUN] Would write marketplace.json at $outputPath" -ForegroundColor Yellow
888 return
889 }
890
891 if (-not (Test-Path -Path $outputDir)) {
892 New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
893 }
894
895 $manifest | ConvertTo-Json -Depth 10 | Set-Content -Path $outputPath -Encoding utf8 -NoNewline
896 Write-Host " Marketplace manifest: $outputPath" -ForegroundColor Green
897}
898
899function New-GenerateResult {
900 <#
901 .SYNOPSIS
902 Creates a standardized result object.
903
904 .DESCRIPTION
905 Returns a hashtable representing the outcome of a plugin generation run
906 with success status, plugin count, and optional error message.
907
908 .PARAMETER Success
909 Whether the operation succeeded.
910
911 .PARAMETER PluginCount
912 Number of plugins generated.
913
914 .PARAMETER ErrorMessage
915 Optional error message when Success is $false.
916
917 .OUTPUTS
918 [hashtable] Result with Success, PluginCount, and ErrorMessage keys.
919 #>
920 [CmdletBinding()]
921 [OutputType([hashtable])]
922 param(
923 [Parameter(Mandatory = $true)]
924 [bool]$Success,
925
926 [Parameter(Mandatory = $true)]
927 [int]$PluginCount,
928
929 [Parameter(Mandatory = $false)]
930 [string]$ErrorMessage = ''
931 )
932
933 return @{
934 Success = $Success
935 PluginCount = $PluginCount
936 ErrorMessage = $ErrorMessage
937 }
938}
939
940# ---------------------------------------------------------------------------
941# I/O Functions (file system operations)
942# ---------------------------------------------------------------------------
943
944function Test-SymlinkCapability {
945 <#
946 .SYNOPSIS
947 Probes whether the current process can create symbolic links.
948
949 .DESCRIPTION
950 Creates a temporary file and attempts to symlink to it. Returns $true
951 when the OS and process privileges allow symlink creation, $false
952 otherwise. The probe directory is cleaned up unconditionally.
953 #>
954 [CmdletBinding()]
955 [OutputType([bool])]
956 param()
957
958 $tempDir = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "hve-symlink-probe-$PID"
959 $targetFile = Join-Path -Path $tempDir -ChildPath 'target.txt'
960 $linkFile = Join-Path -Path $tempDir -ChildPath 'link.txt'
961 try {
962 New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
963 Set-Content -Path $targetFile -Value 'probe' -NoNewline
964 New-Item -ItemType SymbolicLink -Path $linkFile -Target $targetFile -ErrorAction Stop | Out-Null
965 return $true
966 }
967 catch {
968 return $false
969 }
970 finally {
971 if (Test-Path -Path $tempDir) {
972 Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
973 }
974 }
975}
976
977function New-PluginLink {
978 <#
979 .SYNOPSIS
980 Links a source path into a plugin destination via symlink or text stub.
981
982 .DESCRIPTION
983 When SymlinkCapable is set, creates a relative symbolic link from
984 DestinationPath to SourcePath. Otherwise writes a text stub file
985 containing the relative path, matching the format git produces when
986 core.symlinks is false. Text stubs keep git status clean on Windows
987 without Developer Mode or elevated privileges.
988
989 .PARAMETER SourcePath
990 Absolute path to the real file or directory.
991
992 .PARAMETER DestinationPath
993 Absolute path where the link or text stub will be created.
994
995 .PARAMETER SymlinkCapable
996 When set, create a symbolic link; otherwise write a text stub.
997 #>
998 [CmdletBinding()]
999 param(
1000 [Parameter(Mandatory = $true)]
1001 [string]$SourcePath,
1002
1003 [Parameter(Mandatory = $true)]
1004 [string]$DestinationPath,
1005
1006 [Parameter(Mandatory = $false)]
1007 [switch]$SymlinkCapable
1008 )
1009
1010 $destinationDir = Split-Path -Parent $DestinationPath
1011 if (-not (Test-Path -Path $destinationDir)) {
1012 New-Item -ItemType Directory -Path $destinationDir -Force | Out-Null
1013 }
1014
1015 $relativePath = [System.IO.Path]::GetRelativePath($destinationDir, $SourcePath) -replace '\\', '/'
1016
1017 if ($SymlinkCapable) {
1018 New-Item -ItemType SymbolicLink -Path $DestinationPath -Value $relativePath -Force | Out-Null
1019 }
1020 else {
1021 [System.IO.File]::WriteAllText($DestinationPath, $relativePath)
1022 }
1023}
1024
1025function Write-PluginDirectory {
1026 <#
1027 .SYNOPSIS
1028 Creates a complete plugin directory structure from a collection.
1029
1030 .DESCRIPTION
1031 Builds the full plugin layout under the specified plugins directory,
1032 including subdirectories for agents, commands, instructions, and skills.
1033 Each item is linked or copied from the plugin directory back to its
1034 source in the repository. Generates plugin.json and README.md.
1035
1036 .PARAMETER Collection
1037 Parsed collection manifest hashtable with id, name, description, and items.
1038
1039 .PARAMETER PluginsDir
1040 Absolute path to the root plugins output directory.
1041
1042 .PARAMETER RepoRoot
1043 Absolute path to the repository root.
1044
1045 .PARAMETER Version
1046 Semantic version string from the repository package.json.
1047
1048 .PARAMETER Maturity
1049 Optional collection-level maturity string. Forwarded to
1050 New-PluginReadmeContent for experimental notice injection.
1051
1052 .PARAMETER DryRun
1053 When specified, logs actions without creating files or directories.
1054
1055 .PARAMETER SymlinkCapable
1056 When specified, creates symbolic links; otherwise copies files.
1057
1058 .OUTPUTS
1059 [hashtable] Result with Success, AgentCount, CommandCount, InstructionCount,
1060 and SkillCount keys.
1061 #>
1062 [CmdletBinding()]
1063 [OutputType([hashtable])]
1064 param(
1065 [Parameter(Mandatory = $true)]
1066 [hashtable]$Collection,
1067
1068 [Parameter(Mandatory = $true)]
1069 [string]$PluginsDir,
1070
1071 [Parameter(Mandatory = $true)]
1072 [string]$RepoRoot,
1073
1074 [Parameter(Mandatory = $true)]
1075 [string]$Version,
1076
1077 [Parameter(Mandatory = $false)]
1078 [AllowNull()]
1079 [AllowEmptyString()]
1080 [string]$Maturity,
1081
1082 [Parameter(Mandatory = $false)]
1083 [switch]$DryRun,
1084
1085 [Parameter(Mandatory = $false)]
1086 [switch]$SymlinkCapable
1087 )
1088
1089 $collectionId = $Collection.id
1090 $pluginRoot = Join-Path -Path $PluginsDir -ChildPath $collectionId
1091
1092 $counts = @{
1093 AgentCount = 0
1094 CommandCount = 0
1095 InstructionCount = 0
1096 SkillCount = 0
1097 }
1098
1099 $readmeItems = @()
1100
1101 foreach ($item in $Collection.items) {
1102 $kind = $item.kind
1103 $sourcePath = Join-Path -Path $RepoRoot -ChildPath $item.path
1104 $subdir = Get-PluginSubdirectory -Kind $kind
1105
1106 if ($kind -eq 'skill') {
1107 # Skills are directory symlinks; use the directory name as FileName
1108 $fileName = Split-Path -Leaf $item.path
1109 $itemName = Get-PluginItemName -FileName $fileName -Kind $kind
1110 $destPath = Join-Path -Path $pluginRoot -ChildPath $subdir -AdditionalChildPath $itemName
1111 $description = $fileName
1112 }
1113 else {
1114 $fileName = Split-Path -Leaf $item.path
1115 $itemName = Get-PluginItemName -FileName $fileName -Kind $kind
1116 $destPath = Join-Path -Path $pluginRoot -ChildPath $subdir -AdditionalChildPath $itemName
1117
1118 # Read frontmatter from the source file for description
1119 $fallback = $itemName -replace '\.md$', ''
1120 if (Test-Path -Path $sourcePath) {
1121 $frontmatter = Get-ArtifactFrontmatter -FilePath $sourcePath -FallbackDescription $fallback
1122 $description = $frontmatter.description
1123 }
1124 else {
1125 $description = $fallback
1126 Write-Warning "Source file not found: $sourcePath"
1127 }
1128 }
1129
1130 $readmeItems += @{
1131 Name = $itemName -replace '\.md$', ''
1132 Description = $description
1133 Kind = $kind
1134 }
1135
1136 # Update counts
1137 switch ($kind) {
1138 'agent' { $counts.AgentCount++ }
1139 'prompt' { $counts.CommandCount++ }
1140 'instruction' { $counts.InstructionCount++ }
1141 'skill' { $counts.SkillCount++ }
1142 }
1143
1144 if ($DryRun) {
1145 Write-Verbose "DryRun: Would create link $destPath -> $sourcePath"
1146 continue
1147 }
1148
1149 New-PluginLink -SourcePath $sourcePath -DestinationPath $destPath -SymlinkCapable:$SymlinkCapable
1150 }
1151
1152 # Link shared resource directories (unconditional, all plugins)
1153 $sharedDirs = @(
1154 @{ Source = 'docs/templates'; Destination = 'docs/templates' }
1155 @{ Source = 'scripts/lib'; Destination = 'scripts/lib' }
1156 )
1157
1158 foreach ($dir in $sharedDirs) {
1159 $sourcePath = Join-Path -Path $RepoRoot -ChildPath $dir.Source
1160 $destPath = Join-Path -Path $pluginRoot -ChildPath $dir.Destination
1161
1162 if (-not (Test-Path -Path $sourcePath)) {
1163 Write-Warning "Shared directory not found: $sourcePath"
1164 continue
1165 }
1166
1167 if ($DryRun) {
1168 Write-Verbose "DryRun: Would create shared directory link $destPath -> $sourcePath"
1169 continue
1170 }
1171
1172 New-PluginLink -SourcePath $sourcePath -DestinationPath $destPath -SymlinkCapable:$SymlinkCapable
1173 }
1174
1175 # Generate plugin.json
1176 $manifestDir = Join-Path -Path $pluginRoot -ChildPath '.github' -AdditionalChildPath 'plugin'
1177 $manifestPath = Join-Path -Path $manifestDir -ChildPath 'plugin.json'
1178 $manifest = New-PluginManifestContent -CollectionId $collectionId -Description $Collection.description -Version $Version
1179
1180 if ($DryRun) {
1181 Write-Verbose "DryRun: Would write plugin.json at $manifestPath"
1182 }
1183 else {
1184 if (-not (Test-Path -Path $manifestDir)) {
1185 New-Item -ItemType Directory -Path $manifestDir -Force | Out-Null
1186 }
1187 $manifest | ConvertTo-Json -Depth 10 | Set-Content -Path $manifestPath -Encoding utf8 -NoNewline
1188 }
1189
1190 # Generate README.md
1191 $readmePath = Join-Path -Path $pluginRoot -ChildPath 'README.md'
1192 $readmeContent = New-PluginReadmeContent -Collection $Collection -Items $readmeItems -Maturity $Maturity
1193
1194 if ($DryRun) {
1195 Write-Verbose "DryRun: Would write README.md at $readmePath"
1196 }
1197 else {
1198 Set-Content -Path $readmePath -Value $readmeContent -Encoding utf8 -NoNewline
1199 }
1200
1201 return @{
1202 Success = $true
1203 AgentCount = $counts.AgentCount
1204 CommandCount = $counts.CommandCount
1205 InstructionCount = $counts.InstructionCount
1206 SkillCount = $counts.SkillCount
1207 }
1208}
1209
1210function Repair-PluginSymlinkIndex {
1211 <#
1212 .SYNOPSIS
1213 Fixes git index modes for text stub files so they register as symlinks.
1214
1215 .DESCRIPTION
1216 On systems where symlinks are unavailable (Windows without Developer Mode),
1217 New-PluginLink writes text stubs containing relative paths. Git stages
1218 these as mode 100644 (regular file). This function re-indexes each text
1219 stub as mode 120000 (symlink) so that Linux/macOS checkouts materialize
1220 real symbolic links.
1221
1222 .PARAMETER PluginsDir
1223 Absolute path to the plugins output directory.
1224
1225 .PARAMETER RepoRoot
1226 Absolute path to the repository root (git working tree).
1227
1228 .PARAMETER DryRun
1229 When specified, logs what would be fixed without modifying the index.
1230
1231 .OUTPUTS
1232 [int] Number of index entries corrected.
1233 #>
1234 [CmdletBinding()]
1235 [OutputType([int])]
1236 param(
1237 [Parameter(Mandatory = $true)]
1238 [ValidateNotNullOrEmpty()]
1239 [string]$PluginsDir,
1240
1241 [Parameter(Mandatory = $true)]
1242 [ValidateNotNullOrEmpty()]
1243 [string]$RepoRoot,
1244
1245 [Parameter(Mandatory = $false)]
1246 [switch]$DryRun
1247 )
1248
1249 if (-not (Test-Path -Path $PluginsDir)) {
1250 return 0
1251 }
1252
1253 # Build a set of paths already tracked in the git index under plugins/.
1254 # --index-info silently ignores untracked paths (PowerShell pipe encoding
1255 # issue), so new files must be added individually via --cacheinfo.
1256 $trackedPaths = [System.Collections.Generic.HashSet[string]]::new(
1257 [System.StringComparer]::OrdinalIgnoreCase
1258 )
1259 $pluginsRel = [System.IO.Path]::GetRelativePath($RepoRoot, $PluginsDir) -replace '\\', '/'
1260 $lsOutput = git ls-files -- $pluginsRel 2>$null
1261 if ($lsOutput) {
1262 foreach ($p in @($lsOutput)) { [void]$trackedPaths.Add($p) }
1263 }
1264
1265 $fixedCount = 0
1266 $newEntries = [System.Collections.Generic.List[PSCustomObject]]::new()
1267 $batchEntries = [System.Collections.Generic.List[string]]::new()
1268 $files = Get-ChildItem -Path $PluginsDir -File -Recurse
1269
1270 foreach ($file in $files) {
1271 # Text stubs are small files whose content is a relative path with
1272 # forward slashes, no line breaks, starting with ../
1273 if ($file.Length -gt 500) {
1274 continue
1275 }
1276
1277 $content = [System.IO.File]::ReadAllText($file.FullName)
1278
1279 if ($content -notmatch '^\.\./') {
1280 continue
1281 }
1282 if ($content.Contains("`n") -or $content.Contains("`r")) {
1283 continue
1284 }
1285
1286 $repoRelPath = [System.IO.Path]::GetRelativePath($RepoRoot, $file.FullName) -replace '\\', '/'
1287
1288 if ($DryRun) {
1289 Write-Verbose "DryRun: Would fix index mode for $repoRelPath"
1290 $fixedCount++
1291 continue
1292 }
1293
1294 $hashOutput = git hash-object -w -- $file.FullName 2>&1
1295 if ($LASTEXITCODE -ne 0) {
1296 Write-Warning "Failed to hash-object for $repoRelPath"
1297 continue
1298 }
1299
1300 # Extract clean SHA string, filtering out any ErrorRecord objects
1301 $sha = @($hashOutput | Where-Object { $_ -is [string] -and $_ -match '^[0-9a-f]{40}' })[0]
1302 if (-not $sha) {
1303 Write-Warning "No valid SHA returned for $repoRelPath"
1304 continue
1305 }
1306
1307 if ($trackedPaths.Contains($repoRelPath)) {
1308 $batchEntries.Add("120000 $sha`t$repoRelPath")
1309 } else {
1310 $newEntries.Add([PSCustomObject]@{ Sha = $sha; Path = $repoRelPath })
1311 }
1312 $fixedCount++
1313 Write-Verbose "Queued index fix: $repoRelPath -> 120000"
1314 }
1315
1316 # Add new/untracked files individually (typically few per run)
1317 foreach ($entry in $newEntries) {
1318 $cacheResult = git update-index --add --cacheinfo "120000,$($entry.Sha),$($entry.Path)" 2>&1
1319 if ($LASTEXITCODE -ne 0) {
1320 $errorMsg = @($cacheResult | ForEach-Object { $_.ToString() }) -join '; '
1321 Write-Warning "Failed to add index entry for $($entry.Path): $errorMsg"
1322 $fixedCount--
1323 }
1324 }
1325
1326 # Batch update existing entries in a single call to avoid index.lock contention
1327 if ($batchEntries.Count -gt 0) {
1328 $indexResult = $batchEntries | git update-index --index-info 2>&1
1329 if ($LASTEXITCODE -ne 0) {
1330 $errorMsg = @($indexResult | ForEach-Object { $_.ToString() }) -join '; '
1331 Write-Warning "Failed to update git index: $errorMsg"
1332 return 0
1333 }
1334 }
1335
1336 return $fixedCount
1337}
1338
1339Export-ModuleMember -Function @(
1340 'Get-AllCollections',
1341 'Get-ArtifactFiles',
1342 'Get-ArtifactFrontmatter',
1343 'Get-CollectionArtifactKey',
1344 'Get-CollectionManifest',
1345 'Get-PluginItemName',
1346 'Get-PluginSubdirectory',
1347 'New-GenerateResult',
1348 'New-MarketplaceManifestContent',
1349 'New-PluginManifestContent',
1350 'New-PluginReadmeContent',
1351 'New-PluginLink',
1352 'Repair-PluginSymlinkIndex',
1353 'Test-SymlinkCapability',
1354 'Resolve-CollectionItemMaturity',
1355 'Test-ArtifactDeprecated',
1356 'Test-DeprecatedPath',
1357 'Test-HveCoreRepoRelativePath',
1358 'Test-HveCoreRepoSpecificPath',
1359 'Update-HveCoreAllCollection',
1360 'Write-MarketplaceManifest',
1361 'Write-PluginDirectory'
1362)
1363