microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
de5cfd6214cdb0a0196f476bdcf8b665dabd6a1b

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/plugins/Modules/PluginHelpers.psm1

1146lines · 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 .OUTPUTS
676 [string] Complete README markdown content.
677 #>
678 [CmdletBinding()]
679 [OutputType([string])]
680 param(
681 [Parameter(Mandatory = $true)]
682 [hashtable]$Collection,
683
684 [Parameter(Mandatory = $true)]
685 [AllowEmptyCollection()]
686 [array]$Items
687 )
688
689 $sb = [System.Text.StringBuilder]::new()
690 [void]$sb.AppendLine('<!-- markdownlint-disable-file -->')
691 [void]$sb.AppendLine("# $($Collection.name)")
692 [void]$sb.AppendLine()
693 [void]$sb.AppendLine($Collection.description)
694 [void]$sb.AppendLine()
695 [void]$sb.AppendLine('## Install')
696 [void]$sb.AppendLine()
697 [void]$sb.AppendLine('```bash')
698 [void]$sb.AppendLine("copilot plugin install $($Collection.id)@hve-core")
699 [void]$sb.AppendLine('```')
700
701 $sectionMap = [ordered]@{
702 agent = @{ Title = 'Agents'; Header = 'Agent' }
703 prompt = @{ Title = 'Commands'; Header = 'Command' }
704 instruction = @{ Title = 'Instructions'; Header = 'Instruction' }
705 skill = @{ Title = 'Skills'; Header = 'Skill' }
706 }
707
708 foreach ($entry in $sectionMap.GetEnumerator()) {
709 $kind = $entry.Key
710 $meta = $entry.Value
711 $kindItems = @($Items | Where-Object { $_.Kind -eq $kind })
712 if ($kindItems.Count -eq 0) {
713 continue
714 }
715
716 [void]$sb.AppendLine()
717 [void]$sb.AppendLine("## $($meta.Title)")
718 [void]$sb.AppendLine()
719 [void]$sb.AppendLine("| $($meta.Header) | Description |")
720 [void]$sb.AppendLine('| ' + ('-' * $meta.Header.Length) + ' | ----------- |')
721 foreach ($item in $kindItems) {
722 [void]$sb.AppendLine("| $($item.Name) | $($item.Description) |")
723 }
724 }
725
726 [void]$sb.AppendLine()
727 [void]$sb.AppendLine('---')
728 [void]$sb.AppendLine()
729 [void]$sb.AppendLine('> Source: [microsoft/hve-core](https://github.com/microsoft/hve-core)')
730 [void]$sb.AppendLine()
731
732 return $sb.ToString()
733}
734
735function New-MarketplaceManifestContent {
736 <#
737 .SYNOPSIS
738 Generates marketplace.json content as a hashtable.
739
740 .DESCRIPTION
741 Creates a hashtable representing the marketplace manifest with repository
742 metadata, owner information, and plugin entries. Matches the schema used
743 by github/awesome-copilot.
744
745 .PARAMETER RepoName
746 Repository name used as the marketplace name.
747
748 .PARAMETER Description
749 Short description of the repository.
750
751 .PARAMETER Version
752 Semantic version string from package.json.
753
754 .PARAMETER OwnerName
755 Organization or individual owning the repository.
756
757 .PARAMETER Plugins
758 Array of ordered hashtables with name, description, and version keys
759 from New-PluginManifestContent.
760
761 .OUTPUTS
762 [hashtable] Marketplace manifest with name, metadata, owner, and plugins keys.
763 #>
764 [CmdletBinding()]
765 [OutputType([hashtable])]
766 param(
767 [Parameter(Mandatory = $true)]
768 [string]$RepoName,
769
770 [Parameter(Mandatory = $true)]
771 [string]$Description,
772
773 [Parameter(Mandatory = $true)]
774 [string]$Version,
775
776 [Parameter(Mandatory = $true)]
777 [string]$OwnerName,
778
779 [Parameter(Mandatory = $true)]
780 [AllowEmptyCollection()]
781 [array]$Plugins
782 )
783
784 $pluginEntries = @()
785 foreach ($plugin in $Plugins) {
786 $pluginEntries += [ordered]@{
787 name = $plugin.name
788 source = "./plugins/$($plugin.name)"
789 description = $plugin.description
790 version = $plugin.version
791 }
792 }
793
794 return [ordered]@{
795 name = $RepoName
796 metadata = [ordered]@{
797 description = $Description
798 version = $Version
799 pluginRoot = './plugins'
800 }
801 owner = [ordered]@{
802 name = $OwnerName
803 }
804 plugins = $pluginEntries
805 }
806}
807
808function Write-MarketplaceManifest {
809 <#
810 .SYNOPSIS
811 Writes the marketplace.json file to .github/plugin/.
812
813 .DESCRIPTION
814 Assembles plugin metadata from generated collections and writes the
815 marketplace manifest to .github/plugin/marketplace.json. Creates the
816 directory when it does not exist.
817
818 .PARAMETER RepoRoot
819 Absolute path to the repository root directory.
820
821 .PARAMETER Collections
822 Array of collection manifest hashtables with id and description.
823
824 .PARAMETER DryRun
825 When specified, logs the action without writing to disk.
826 #>
827 [CmdletBinding()]
828 param(
829 [Parameter(Mandatory = $true)]
830 [ValidateNotNullOrEmpty()]
831 [string]$RepoRoot,
832
833 [Parameter(Mandatory = $true)]
834 [AllowEmptyCollection()]
835 [array]$Collections,
836
837 [Parameter(Mandatory = $false)]
838 [switch]$DryRun
839 )
840
841 $packageJsonPath = Join-Path -Path $RepoRoot -ChildPath 'package.json'
842 $packageJson = Get-Content -Path $packageJsonPath -Raw | ConvertFrom-Json
843
844 $plugins = @()
845 foreach ($collection in ($Collections | Sort-Object { $_.id })) {
846 $plugins += New-PluginManifestContent `
847 -CollectionId $collection.id `
848 -Description $collection.description `
849 -Version $packageJson.version
850 }
851
852 $manifest = New-MarketplaceManifestContent `
853 -RepoName $packageJson.name `
854 -Description $packageJson.description `
855 -Version $packageJson.version `
856 -OwnerName $packageJson.author `
857 -Plugins $plugins
858
859 $outputDir = Join-Path -Path $RepoRoot -ChildPath '.github' -AdditionalChildPath 'plugin'
860 $outputPath = Join-Path -Path $outputDir -ChildPath 'marketplace.json'
861
862 if ($DryRun) {
863 Write-Host " [DRY RUN] Would write marketplace.json at $outputPath" -ForegroundColor Yellow
864 return
865 }
866
867 if (-not (Test-Path -Path $outputDir)) {
868 New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
869 }
870
871 $manifest | ConvertTo-Json -Depth 10 | Set-Content -Path $outputPath -Encoding utf8 -NoNewline
872 Write-Host " Marketplace manifest: $outputPath" -ForegroundColor Green
873}
874
875function New-GenerateResult {
876 <#
877 .SYNOPSIS
878 Creates a standardized result object.
879
880 .DESCRIPTION
881 Returns a hashtable representing the outcome of a plugin generation run
882 with success status, plugin count, and optional error message.
883
884 .PARAMETER Success
885 Whether the operation succeeded.
886
887 .PARAMETER PluginCount
888 Number of plugins generated.
889
890 .PARAMETER ErrorMessage
891 Optional error message when Success is $false.
892
893 .OUTPUTS
894 [hashtable] Result with Success, PluginCount, and ErrorMessage keys.
895 #>
896 [CmdletBinding()]
897 [OutputType([hashtable])]
898 param(
899 [Parameter(Mandatory = $true)]
900 [bool]$Success,
901
902 [Parameter(Mandatory = $true)]
903 [int]$PluginCount,
904
905 [Parameter(Mandatory = $false)]
906 [string]$ErrorMessage = ''
907 )
908
909 return @{
910 Success = $Success
911 PluginCount = $PluginCount
912 ErrorMessage = $ErrorMessage
913 }
914}
915
916# ---------------------------------------------------------------------------
917# I/O Functions (file system operations)
918# ---------------------------------------------------------------------------
919
920function New-RelativeSymlink {
921 <#
922 .SYNOPSIS
923 Creates a relative symlink from destination to source.
924
925 .DESCRIPTION
926 Calculates the relative path from the directory containing the destination
927 to the source path, then creates a symbolic link at the destination
928 pointing to that relative path.
929
930 .PARAMETER SourcePath
931 Absolute path to the symlink target (the real file or directory).
932
933 .PARAMETER DestinationPath
934 Absolute path where the symlink will be created.
935 #>
936 [CmdletBinding()]
937 param(
938 [Parameter(Mandatory = $true)]
939 [string]$SourcePath,
940
941 [Parameter(Mandatory = $true)]
942 [string]$DestinationPath
943 )
944
945 $destinationDir = Split-Path -Parent $DestinationPath
946 $relativePath = [System.IO.Path]::GetRelativePath($destinationDir, $SourcePath)
947
948 if (-not (Test-Path -Path $destinationDir)) {
949 New-Item -ItemType Directory -Path $destinationDir -Force | Out-Null
950 }
951
952 New-Item -ItemType SymbolicLink -Path $DestinationPath -Value $relativePath -Force | Out-Null
953}
954
955function Write-PluginDirectory {
956 <#
957 .SYNOPSIS
958 Creates a complete plugin directory structure from a collection.
959
960 .DESCRIPTION
961 Builds the full plugin layout under the specified plugins directory,
962 including subdirectories for agents, commands, instructions, and skills.
963 Each item is symlinked from the plugin directory back to its source in
964 the repository. Generates plugin.json and README.md.
965
966 .PARAMETER Collection
967 Parsed collection manifest hashtable with id, name, description, and items.
968
969 .PARAMETER PluginsDir
970 Absolute path to the root plugins output directory.
971
972 .PARAMETER RepoRoot
973 Absolute path to the repository root.
974
975 .PARAMETER Version
976 Semantic version string from the repository package.json.
977
978 .PARAMETER DryRun
979 When specified, logs actions without creating files or directories.
980
981 .OUTPUTS
982 [hashtable] Result with Success, AgentCount, CommandCount, InstructionCount,
983 and SkillCount keys.
984 #>
985 [CmdletBinding()]
986 [OutputType([hashtable])]
987 param(
988 [Parameter(Mandatory = $true)]
989 [hashtable]$Collection,
990
991 [Parameter(Mandatory = $true)]
992 [string]$PluginsDir,
993
994 [Parameter(Mandatory = $true)]
995 [string]$RepoRoot,
996
997 [Parameter(Mandatory = $true)]
998 [string]$Version,
999
1000 [Parameter(Mandatory = $false)]
1001 [switch]$DryRun
1002 )
1003
1004 $collectionId = $Collection.id
1005 $pluginRoot = Join-Path -Path $PluginsDir -ChildPath $collectionId
1006
1007 $counts = @{
1008 AgentCount = 0
1009 CommandCount = 0
1010 InstructionCount = 0
1011 SkillCount = 0
1012 }
1013
1014 $readmeItems = @()
1015
1016 foreach ($item in $Collection.items) {
1017 $kind = $item.kind
1018 $sourcePath = Join-Path -Path $RepoRoot -ChildPath $item.path
1019 $subdir = Get-PluginSubdirectory -Kind $kind
1020
1021 if ($kind -eq 'skill') {
1022 # Skills are directory symlinks; use the directory name as FileName
1023 $fileName = Split-Path -Leaf $item.path
1024 $itemName = Get-PluginItemName -FileName $fileName -Kind $kind
1025 $destPath = Join-Path -Path $pluginRoot -ChildPath $subdir -AdditionalChildPath $itemName
1026 $description = $fileName
1027 }
1028 else {
1029 $fileName = Split-Path -Leaf $item.path
1030 $itemName = Get-PluginItemName -FileName $fileName -Kind $kind
1031 $destPath = Join-Path -Path $pluginRoot -ChildPath $subdir -AdditionalChildPath $itemName
1032
1033 # Read frontmatter from the source file for description
1034 $fallback = $itemName -replace '\.md$', ''
1035 if (Test-Path -Path $sourcePath) {
1036 $frontmatter = Get-ArtifactFrontmatter -FilePath $sourcePath -FallbackDescription $fallback
1037 $description = $frontmatter.description
1038 }
1039 else {
1040 $description = $fallback
1041 Write-Warning "Source file not found: $sourcePath"
1042 }
1043 }
1044
1045 $readmeItems += @{
1046 Name = $itemName -replace '\.md$', ''
1047 Description = $description
1048 Kind = $kind
1049 }
1050
1051 # Update counts
1052 switch ($kind) {
1053 'agent' { $counts.AgentCount++ }
1054 'prompt' { $counts.CommandCount++ }
1055 'instruction' { $counts.InstructionCount++ }
1056 'skill' { $counts.SkillCount++ }
1057 }
1058
1059 if ($DryRun) {
1060 Write-Verbose "DryRun: Would create symlink $destPath -> $sourcePath"
1061 continue
1062 }
1063
1064 New-RelativeSymlink -SourcePath $sourcePath -DestinationPath $destPath
1065 }
1066
1067 # Symlink shared resource directories (unconditional, all plugins)
1068 $sharedDirs = @(
1069 @{ Source = 'docs/templates'; Destination = 'docs/templates' }
1070 @{ Source = 'scripts/lib'; Destination = 'scripts/lib' }
1071 )
1072
1073 foreach ($dir in $sharedDirs) {
1074 $sourcePath = Join-Path -Path $RepoRoot -ChildPath $dir.Source
1075 $destPath = Join-Path -Path $pluginRoot -ChildPath $dir.Destination
1076
1077 if (-not (Test-Path -Path $sourcePath)) {
1078 Write-Warning "Shared directory not found: $sourcePath"
1079 continue
1080 }
1081
1082 if ($DryRun) {
1083 Write-Verbose "DryRun: Would create shared directory symlink $destPath -> $sourcePath"
1084 continue
1085 }
1086
1087 New-RelativeSymlink -SourcePath $sourcePath -DestinationPath $destPath
1088 }
1089
1090 # Generate plugin.json
1091 $manifestDir = Join-Path -Path $pluginRoot -ChildPath '.github' -AdditionalChildPath 'plugin'
1092 $manifestPath = Join-Path -Path $manifestDir -ChildPath 'plugin.json'
1093 $manifest = New-PluginManifestContent -CollectionId $collectionId -Description $Collection.description -Version $Version
1094
1095 if ($DryRun) {
1096 Write-Verbose "DryRun: Would write plugin.json at $manifestPath"
1097 }
1098 else {
1099 if (-not (Test-Path -Path $manifestDir)) {
1100 New-Item -ItemType Directory -Path $manifestDir -Force | Out-Null
1101 }
1102 $manifest | ConvertTo-Json -Depth 10 | Set-Content -Path $manifestPath -Encoding utf8 -NoNewline
1103 }
1104
1105 # Generate README.md
1106 $readmePath = Join-Path -Path $pluginRoot -ChildPath 'README.md'
1107 $readmeContent = New-PluginReadmeContent -Collection $Collection -Items $readmeItems
1108
1109 if ($DryRun) {
1110 Write-Verbose "DryRun: Would write README.md at $readmePath"
1111 }
1112 else {
1113 Set-Content -Path $readmePath -Value $readmeContent -Encoding utf8 -NoNewline
1114 }
1115
1116 return @{
1117 Success = $true
1118 AgentCount = $counts.AgentCount
1119 CommandCount = $counts.CommandCount
1120 InstructionCount = $counts.InstructionCount
1121 SkillCount = $counts.SkillCount
1122 }
1123}
1124
1125Export-ModuleMember -Function @(
1126 'Get-AllCollections',
1127 'Get-ArtifactFiles',
1128 'Get-ArtifactFrontmatter',
1129 'Get-CollectionArtifactKey',
1130 'Get-CollectionManifest',
1131 'Get-PluginItemName',
1132 'Get-PluginSubdirectory',
1133 'New-GenerateResult',
1134 'New-MarketplaceManifestContent',
1135 'New-PluginManifestContent',
1136 'New-PluginReadmeContent',
1137 'New-RelativeSymlink',
1138 'Resolve-CollectionItemMaturity',
1139 'Test-ArtifactDeprecated',
1140 'Test-DeprecatedPath',
1141 'Test-HveCoreRepoRelativePath',
1142 'Test-HveCoreRepoSpecificPath',
1143 'Update-HveCoreAllCollection',
1144 'Write-MarketplaceManifest',
1145 'Write-PluginDirectory'
1146)
1147