microsoft/hve-core

Public

mirrored from https://github.com/microsoft/hve-coreAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
f976449aa637cc0b12669bf6478df53862a94cbc

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/plugins/Modules/PluginHelpers.psm1

1172lines · 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 New-RelativeSymlink {
938 <#
939 .SYNOPSIS
940 Creates a relative symlink from destination to source.
941
942 .DESCRIPTION
943 Calculates the relative path from the directory containing the destination
944 to the source path, then creates a symbolic link at the destination
945 pointing to that relative path.
946
947 .PARAMETER SourcePath
948 Absolute path to the symlink target (the real file or directory).
949
950 .PARAMETER DestinationPath
951 Absolute path where the symlink will be created.
952 #>
953 [CmdletBinding()]
954 param(
955 [Parameter(Mandatory = $true)]
956 [string]$SourcePath,
957
958 [Parameter(Mandatory = $true)]
959 [string]$DestinationPath
960 )
961
962 $destinationDir = Split-Path -Parent $DestinationPath
963 $relativePath = [System.IO.Path]::GetRelativePath($destinationDir, $SourcePath)
964
965 if (-not (Test-Path -Path $destinationDir)) {
966 New-Item -ItemType Directory -Path $destinationDir -Force | Out-Null
967 }
968
969 New-Item -ItemType SymbolicLink -Path $DestinationPath -Value $relativePath -Force | Out-Null
970}
971
972function Write-PluginDirectory {
973 <#
974 .SYNOPSIS
975 Creates a complete plugin directory structure from a collection.
976
977 .DESCRIPTION
978 Builds the full plugin layout under the specified plugins directory,
979 including subdirectories for agents, commands, instructions, and skills.
980 Each item is symlinked from the plugin directory back to its source in
981 the repository. Generates plugin.json and README.md.
982
983 .PARAMETER Collection
984 Parsed collection manifest hashtable with id, name, description, and items.
985
986 .PARAMETER PluginsDir
987 Absolute path to the root plugins output directory.
988
989 .PARAMETER RepoRoot
990 Absolute path to the repository root.
991
992 .PARAMETER Version
993 Semantic version string from the repository package.json.
994
995 .PARAMETER Maturity
996 Optional collection-level maturity string. Forwarded to
997 New-PluginReadmeContent for experimental notice injection.
998
999 .PARAMETER DryRun
1000 When specified, logs actions without creating files or directories.
1001
1002 .OUTPUTS
1003 [hashtable] Result with Success, AgentCount, CommandCount, InstructionCount,
1004 and SkillCount keys.
1005 #>
1006 [CmdletBinding()]
1007 [OutputType([hashtable])]
1008 param(
1009 [Parameter(Mandatory = $true)]
1010 [hashtable]$Collection,
1011
1012 [Parameter(Mandatory = $true)]
1013 [string]$PluginsDir,
1014
1015 [Parameter(Mandatory = $true)]
1016 [string]$RepoRoot,
1017
1018 [Parameter(Mandatory = $true)]
1019 [string]$Version,
1020
1021 [Parameter(Mandatory = $false)]
1022 [AllowNull()]
1023 [AllowEmptyString()]
1024 [string]$Maturity,
1025
1026 [Parameter(Mandatory = $false)]
1027 [switch]$DryRun
1028 )
1029
1030 $collectionId = $Collection.id
1031 $pluginRoot = Join-Path -Path $PluginsDir -ChildPath $collectionId
1032
1033 $counts = @{
1034 AgentCount = 0
1035 CommandCount = 0
1036 InstructionCount = 0
1037 SkillCount = 0
1038 }
1039
1040 $readmeItems = @()
1041
1042 foreach ($item in $Collection.items) {
1043 $kind = $item.kind
1044 $sourcePath = Join-Path -Path $RepoRoot -ChildPath $item.path
1045 $subdir = Get-PluginSubdirectory -Kind $kind
1046
1047 if ($kind -eq 'skill') {
1048 # Skills are directory symlinks; use the directory name as FileName
1049 $fileName = Split-Path -Leaf $item.path
1050 $itemName = Get-PluginItemName -FileName $fileName -Kind $kind
1051 $destPath = Join-Path -Path $pluginRoot -ChildPath $subdir -AdditionalChildPath $itemName
1052 $description = $fileName
1053 }
1054 else {
1055 $fileName = Split-Path -Leaf $item.path
1056 $itemName = Get-PluginItemName -FileName $fileName -Kind $kind
1057 $destPath = Join-Path -Path $pluginRoot -ChildPath $subdir -AdditionalChildPath $itemName
1058
1059 # Read frontmatter from the source file for description
1060 $fallback = $itemName -replace '\.md$', ''
1061 if (Test-Path -Path $sourcePath) {
1062 $frontmatter = Get-ArtifactFrontmatter -FilePath $sourcePath -FallbackDescription $fallback
1063 $description = $frontmatter.description
1064 }
1065 else {
1066 $description = $fallback
1067 Write-Warning "Source file not found: $sourcePath"
1068 }
1069 }
1070
1071 $readmeItems += @{
1072 Name = $itemName -replace '\.md$', ''
1073 Description = $description
1074 Kind = $kind
1075 }
1076
1077 # Update counts
1078 switch ($kind) {
1079 'agent' { $counts.AgentCount++ }
1080 'prompt' { $counts.CommandCount++ }
1081 'instruction' { $counts.InstructionCount++ }
1082 'skill' { $counts.SkillCount++ }
1083 }
1084
1085 if ($DryRun) {
1086 Write-Verbose "DryRun: Would create symlink $destPath -> $sourcePath"
1087 continue
1088 }
1089
1090 New-RelativeSymlink -SourcePath $sourcePath -DestinationPath $destPath
1091 }
1092
1093 # Symlink shared resource directories (unconditional, all plugins)
1094 $sharedDirs = @(
1095 @{ Source = 'docs/templates'; Destination = 'docs/templates' }
1096 @{ Source = 'scripts/lib'; Destination = 'scripts/lib' }
1097 )
1098
1099 foreach ($dir in $sharedDirs) {
1100 $sourcePath = Join-Path -Path $RepoRoot -ChildPath $dir.Source
1101 $destPath = Join-Path -Path $pluginRoot -ChildPath $dir.Destination
1102
1103 if (-not (Test-Path -Path $sourcePath)) {
1104 Write-Warning "Shared directory not found: $sourcePath"
1105 continue
1106 }
1107
1108 if ($DryRun) {
1109 Write-Verbose "DryRun: Would create shared directory symlink $destPath -> $sourcePath"
1110 continue
1111 }
1112
1113 New-RelativeSymlink -SourcePath $sourcePath -DestinationPath $destPath
1114 }
1115
1116 # Generate plugin.json
1117 $manifestDir = Join-Path -Path $pluginRoot -ChildPath '.github' -AdditionalChildPath 'plugin'
1118 $manifestPath = Join-Path -Path $manifestDir -ChildPath 'plugin.json'
1119 $manifest = New-PluginManifestContent -CollectionId $collectionId -Description $Collection.description -Version $Version
1120
1121 if ($DryRun) {
1122 Write-Verbose "DryRun: Would write plugin.json at $manifestPath"
1123 }
1124 else {
1125 if (-not (Test-Path -Path $manifestDir)) {
1126 New-Item -ItemType Directory -Path $manifestDir -Force | Out-Null
1127 }
1128 $manifest | ConvertTo-Json -Depth 10 | Set-Content -Path $manifestPath -Encoding utf8 -NoNewline
1129 }
1130
1131 # Generate README.md
1132 $readmePath = Join-Path -Path $pluginRoot -ChildPath 'README.md'
1133 $readmeContent = New-PluginReadmeContent -Collection $Collection -Items $readmeItems -Maturity $Maturity
1134
1135 if ($DryRun) {
1136 Write-Verbose "DryRun: Would write README.md at $readmePath"
1137 }
1138 else {
1139 Set-Content -Path $readmePath -Value $readmeContent -Encoding utf8 -NoNewline
1140 }
1141
1142 return @{
1143 Success = $true
1144 AgentCount = $counts.AgentCount
1145 CommandCount = $counts.CommandCount
1146 InstructionCount = $counts.InstructionCount
1147 SkillCount = $counts.SkillCount
1148 }
1149}
1150
1151Export-ModuleMember -Function @(
1152 'Get-AllCollections',
1153 'Get-ArtifactFiles',
1154 'Get-ArtifactFrontmatter',
1155 'Get-CollectionArtifactKey',
1156 'Get-CollectionManifest',
1157 'Get-PluginItemName',
1158 'Get-PluginSubdirectory',
1159 'New-GenerateResult',
1160 'New-MarketplaceManifestContent',
1161 'New-PluginManifestContent',
1162 'New-PluginReadmeContent',
1163 'New-RelativeSymlink',
1164 'Resolve-CollectionItemMaturity',
1165 'Test-ArtifactDeprecated',
1166 'Test-DeprecatedPath',
1167 'Test-HveCoreRepoRelativePath',
1168 'Test-HveCoreRepoSpecificPath',
1169 'Update-HveCoreAllCollection',
1170 'Write-MarketplaceManifest',
1171 'Write-PluginDirectory'
1172)