microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
hve-core-v2.3.6

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/plugins/Modules/PluginHelpers.psm1

987lines · 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 Get-CollectionManifest {
16 <#
17 .SYNOPSIS
18 Reads and parses a .collection.yml file.
19
20 .DESCRIPTION
21 Loads a collection manifest YAML file and returns its parsed content
22 as a hashtable using ConvertFrom-Yaml.
23
24 .PARAMETER CollectionPath
25 Absolute or relative path to the .collection.yml file.
26
27 .OUTPUTS
28 [hashtable] Parsed collection data with id, name, description, items, etc.
29 #>
30 [CmdletBinding()]
31 [OutputType([hashtable])]
32 param(
33 [Parameter(Mandatory = $true)]
34 [string]$CollectionPath
35 )
36
37 $content = Get-Content -Path $CollectionPath -Raw
38 $manifest = ConvertFrom-Yaml -Yaml $content
39
40 return $manifest
41}
42
43function Get-ArtifactFrontmatter {
44 <#
45 .SYNOPSIS
46 Extracts YAML frontmatter from a markdown file.
47
48 .DESCRIPTION
49 Parses the YAML frontmatter block delimited by --- markers at the start
50 of a markdown file. Returns a hashtable with description.
51
52 .PARAMETER FilePath
53 Path to the markdown file to parse.
54
55 .PARAMETER FallbackDescription
56 Default description if none found in frontmatter.
57
58 .OUTPUTS
59 [hashtable] With description key.
60 #>
61 [CmdletBinding()]
62 [OutputType([hashtable])]
63 param(
64 [Parameter(Mandatory = $true)]
65 [string]$FilePath,
66
67 [Parameter(Mandatory = $false)]
68 [string]$FallbackDescription = ''
69 )
70
71 $content = Get-Content -Path $FilePath -Raw
72 $description = ''
73
74 if ($content -match '(?s)^---\s*\r?\n(.*?)\r?\n---') {
75 $yamlContent = $Matches[1] -replace '\r\n', "`n" -replace '\r', "`n"
76 try {
77 $data = ConvertFrom-Yaml -Yaml $yamlContent
78 if ($data.ContainsKey('description')) {
79 $description = $data.description
80 }
81 }
82 catch {
83 Write-Warning "Failed to parse YAML frontmatter in $(Split-Path -Leaf $FilePath): $_"
84 }
85 }
86
87 return @{
88 description = if ($description) { $description } else { $FallbackDescription }
89 }
90}
91
92function Resolve-CollectionItemMaturity {
93 <#
94 .SYNOPSIS
95 Resolves effective maturity from collection item metadata.
96
97 .DESCRIPTION
98 Returns stable when maturity is omitted; otherwise returns the provided
99 maturity string.
100
101 .PARAMETER Maturity
102 Optional maturity value from a collection item.
103
104 .OUTPUTS
105 [string] Effective maturity value.
106 #>
107 [CmdletBinding()]
108 [OutputType([string])]
109 param(
110 [Parameter()]
111 [AllowNull()]
112 [AllowEmptyString()]
113 [string]$Maturity
114 )
115
116 if ([string]::IsNullOrWhiteSpace($Maturity)) {
117 return 'stable'
118 }
119
120 return $Maturity
121}
122
123function Get-AllCollections {
124 <#
125 .SYNOPSIS
126 Discovers and parses all .collection.yml files in a directory.
127
128 .DESCRIPTION
129 Scans the specified directory for files matching *.collection.yml and
130 parses each one into a hashtable via Get-CollectionManifest.
131
132 .PARAMETER CollectionsDir
133 Path to the directory containing .collection.yml files.
134
135 .OUTPUTS
136 [hashtable[]] Array of parsed collection manifests.
137 #>
138 [CmdletBinding()]
139 [OutputType([hashtable[]])]
140 param(
141 [Parameter(Mandatory = $true)]
142 [string]$CollectionsDir
143 )
144
145 $files = Get-ChildItem -Path $CollectionsDir -Filter '*.collection.yml' -File
146 $collections = @()
147
148 foreach ($file in $files) {
149 $manifest = Get-CollectionManifest -CollectionPath $file.FullName
150 $collections += $manifest
151 }
152
153 return $collections
154}
155
156function Get-ArtifactFiles {
157 <#
158 .SYNOPSIS
159 Discovers all artifact files from .github/ directories.
160
161 .DESCRIPTION
162 Scans .github/agents/, .github/prompts/, .github/instructions/ (recursively),
163 and .github/skills/ to build a complete list of collection items. Returns
164 repo-relative paths with forward slashes.
165
166 .PARAMETER RepoRoot
167 Absolute path to the repository root directory.
168
169 .OUTPUTS
170 [hashtable[]] Array of hashtables with path and kind keys.
171 #>
172 [CmdletBinding()]
173 [OutputType([hashtable[]])]
174 param(
175 [Parameter(Mandatory = $true)]
176 [ValidateNotNullOrEmpty()]
177 [string]$RepoRoot
178 )
179
180 $items = @()
181
182 # Prompt-engineering artifacts discovered by .<kind>.md suffix under .github/
183 # Keep explicit suffix mapping only where naming differs from manifest kind values.
184 $gitHubDir = Join-Path -Path $RepoRoot -ChildPath '.github'
185 if (Test-Path -Path $gitHubDir) {
186 $suffixToKind = @{
187 instructions = 'instruction'
188 }
189
190 $artifactFiles = Get-ChildItem -Path $gitHubDir -Filter '*.*.md' -File -Recurse
191 foreach ($file in $artifactFiles) {
192 if ($file.Name -notmatch '\.(?<suffix>[^.]+)\.md$') {
193 continue
194 }
195
196 $suffix = $Matches['suffix'].ToLowerInvariant()
197 $kind = if ($suffixToKind.ContainsKey($suffix)) { $suffixToKind[$suffix] } else { $suffix }
198 $relativePath = [System.IO.Path]::GetRelativePath($RepoRoot, $file.FullName) -replace '\\', '/'
199
200 # Exclude repo-specific artifacts under .github/**/hve-core/
201 if ($relativePath -match '^\.github/.*/hve-core/') {
202 continue
203 }
204
205 $items += @{ path = $relativePath; kind = $kind }
206 }
207 }
208
209 # Skills (directories containing SKILL.md)
210 $skillsDir = Join-Path -Path $RepoRoot -ChildPath '.github/skills'
211 if (Test-Path -Path $skillsDir) {
212 $skillDirs = Get-ChildItem -Path $skillsDir -Directory
213 foreach ($dir in $skillDirs) {
214 $skillFile = Join-Path -Path $dir.FullName -ChildPath 'SKILL.md'
215 if (Test-Path -Path $skillFile) {
216 $relativePath = [System.IO.Path]::GetRelativePath($RepoRoot, $dir.FullName) -replace '\\', '/'
217 $items += @{ path = $relativePath; kind = 'skill' }
218 }
219 }
220 }
221
222 return $items
223}
224
225function Test-ArtifactDeprecated {
226 <#
227 .SYNOPSIS
228 Checks whether an artifact has maturity deprecated in collection metadata.
229
230 .DESCRIPTION
231 Reads maturity from the provided collection item metadata value and
232 returns $true when the effective value equals deprecated.
233
234 .PARAMETER Maturity
235 Optional maturity value from collection item metadata.
236
237 .OUTPUTS
238 [bool] True when the artifact is deprecated.
239 #>
240 [CmdletBinding()]
241 [OutputType([bool])]
242 param(
243 [Parameter()]
244 [AllowNull()]
245 [AllowEmptyString()]
246 [string]$Maturity
247 )
248
249 return ((Resolve-CollectionItemMaturity -Maturity $Maturity) -eq 'deprecated')
250}
251
252function Update-HveCoreAllCollection {
253 <#
254 .SYNOPSIS
255 Auto-updates hve-core-all.collection.yml with all non-deprecated artifacts.
256
257 .DESCRIPTION
258 Discovers all artifacts from .github/ directories, excludes deprecated items,
259 and rewrites the hve-core-all collection manifest. Preserves existing
260 metadata fields (id, name, description, tags, display).
261
262 .PARAMETER RepoRoot
263 Absolute path to the repository root directory.
264
265 .PARAMETER DryRun
266 When specified, logs changes without writing to disk.
267
268 .OUTPUTS
269 [hashtable] With ItemCount, AddedCount, RemovedCount, and DeprecatedCount keys.
270 #>
271 [CmdletBinding()]
272 [OutputType([hashtable])]
273 param(
274 [Parameter(Mandatory = $true)]
275 [ValidateNotNullOrEmpty()]
276 [string]$RepoRoot,
277
278 [Parameter(Mandatory = $false)]
279 [switch]$DryRun
280 )
281
282 $collectionPath = Join-Path -Path $RepoRoot -ChildPath 'collections/hve-core-all.collection.yml'
283
284 # Read existing manifest to preserve metadata
285 $existing = Get-CollectionManifest -CollectionPath $collectionPath
286 $existingPaths = @($existing.items | ForEach-Object { $_.path })
287
288 # Discover all artifacts
289 $allItems = Get-ArtifactFiles -RepoRoot $RepoRoot
290
291 # Filter deprecated based on existing collection item maturity metadata
292 $existingItemMaturities = @{}
293 foreach ($existingItem in $existing.items) {
294 $existingKey = "$($existingItem.kind)|$($existingItem.path)"
295 $existingItemMaturities[$existingKey] = Resolve-CollectionItemMaturity -Maturity $existingItem.maturity
296 }
297
298 $deprecatedCount = 0
299 $filteredItems = @()
300 foreach ($item in $allItems) {
301 $itemKey = "$($item.kind)|$($item.path)"
302 $itemMaturity = 'stable'
303 if ($existingItemMaturities.ContainsKey($itemKey)) {
304 $itemMaturity = $existingItemMaturities[$itemKey]
305 }
306
307 if (Test-ArtifactDeprecated -Maturity $itemMaturity) {
308 $deprecatedCount++
309 Write-Verbose "Excluding deprecated: $($item.path)"
310 continue
311 }
312
313 $filteredItems += @{
314 path = $item.path
315 kind = $item.kind
316 maturity = $itemMaturity
317 }
318 }
319
320 # Sort: known kinds first, then any additional kinds, then by path
321 $kindOrder = @{ 'agent' = 0; 'prompt' = 1; 'instruction' = 2; 'skill' = 3 }
322 $sortedItems = $filteredItems | Sort-Object `
323 { if ($kindOrder.ContainsKey($_.kind)) { $kindOrder[$_.kind] } else { 100 } }, `
324 { $_.kind }, `
325 { $_.path }
326
327 # Build new items array as ordered hashtables for clean YAML output
328 $newItems = @()
329 foreach ($item in $sortedItems) {
330 $newItem = [ordered]@{
331 path = $item.path
332 kind = $item.kind
333 }
334
335 if ((Resolve-CollectionItemMaturity -Maturity $item.maturity) -ne 'stable') {
336 $newItem['maturity'] = $item.maturity
337 }
338
339 $newItems += $newItem
340 }
341
342 # Compute diff
343 $newPaths = @($sortedItems | ForEach-Object { $_.path })
344 $added = @($newPaths | Where-Object { $_ -notin $existingPaths })
345 $removed = @($existingPaths | Where-Object { $_ -notin $newPaths })
346
347 Write-Host "`n--- hve-core-all Auto-Update ---" -ForegroundColor Cyan
348 Write-Host " Discovered: $($allItems.Count) artifacts"
349 Write-Host " Deprecated: $deprecatedCount (excluded)"
350 Write-Host " Final: $($newItems.Count) items"
351 if ($added.Count -gt 0) {
352 Write-Host " Added: $($added -join ', ')" -ForegroundColor Green
353 }
354 if ($removed.Count -gt 0) {
355 Write-Host " Removed: $($removed -join ', ')" -ForegroundColor Yellow
356 }
357
358 if ($DryRun) {
359 Write-Host ' [DRY RUN] No changes written' -ForegroundColor Yellow
360 }
361 else {
362 # Rebuild manifest preserving metadata
363 $manifest = [ordered]@{
364 id = $existing.id
365 name = $existing.name
366 description = $existing.description
367 tags = $existing.tags
368 items = $newItems
369 display = $existing.display
370 }
371
372 $yaml = ConvertTo-Yaml -Data $manifest
373 Set-Content -Path $collectionPath -Value $yaml -Encoding utf8 -NoNewline
374 Write-Verbose "Updated $collectionPath"
375 }
376
377 return @{
378 ItemCount = $newItems.Count
379 AddedCount = $added.Count
380 RemovedCount = $removed.Count
381 DeprecatedCount = $deprecatedCount
382 }
383}
384
385function Get-PluginItemName {
386 <#
387 .SYNOPSIS
388 Strips artifact-type suffix from a filename.
389
390 .DESCRIPTION
391 Removes the kind-specific suffix from a filename and returns the
392 simplified name with a .md extension (or the directory name for skills).
393
394 .PARAMETER FileName
395 The original filename (e.g. task-researcher.agent.md).
396
397 .PARAMETER Kind
398 The artifact kind: agent, prompt, instruction, or skill.
399
400 .OUTPUTS
401 [string] The simplified item name.
402 #>
403 [CmdletBinding()]
404 [OutputType([string])]
405 param(
406 [Parameter(Mandatory = $true)]
407 [string]$FileName,
408
409 [Parameter(Mandatory = $true)]
410 [ValidateSet('agent', 'prompt', 'instruction', 'skill')]
411 [string]$Kind
412 )
413
414 switch ($Kind) {
415 'agent' {
416 return ($FileName -replace '\.agent\.md$', '') + '.md'
417 }
418 'prompt' {
419 return ($FileName -replace '\.prompt\.md$', '') + '.md'
420 }
421 'instruction' {
422 return ($FileName -replace '\.instructions\.md$', '') + '.md'
423 }
424 'skill' {
425 return $FileName
426 }
427 }
428}
429
430function Get-PluginSubdirectory {
431 <#
432 .SYNOPSIS
433 Returns the plugin subdirectory name for an artifact kind.
434
435 .DESCRIPTION
436 Maps a collection item kind to the corresponding subdirectory name
437 within the plugin directory structure.
438
439 .PARAMETER Kind
440 The artifact kind: agent, prompt, instruction, or skill.
441
442 .OUTPUTS
443 [string] The subdirectory name (agents, commands, instructions, or skills).
444 #>
445 [CmdletBinding()]
446 [OutputType([string])]
447 param(
448 [Parameter(Mandatory = $true)]
449 [ValidateSet('agent', 'prompt', 'instruction', 'skill')]
450 [string]$Kind
451 )
452
453 switch ($Kind) {
454 'agent' { return 'agents' }
455 'prompt' { return 'commands' }
456 'instruction' { return 'instructions' }
457 'skill' { return 'skills' }
458 }
459}
460
461function New-PluginManifestContent {
462 <#
463 .SYNOPSIS
464 Generates plugin.json content as a hashtable.
465
466 .DESCRIPTION
467 Creates a hashtable representing the plugin manifest with name,
468 description, and version sourced from the repository package.json.
469
470 .PARAMETER CollectionId
471 The collection identifier used as the plugin name.
472
473 .PARAMETER Description
474 A short description of the plugin.
475
476 .PARAMETER Version
477 Semantic version string from the repository package.json.
478
479 .OUTPUTS
480 [hashtable] Plugin manifest with name, description, and version keys.
481 #>
482 [CmdletBinding()]
483 [OutputType([hashtable])]
484 param(
485 [Parameter(Mandatory = $true)]
486 [string]$CollectionId,
487
488 [Parameter(Mandatory = $true)]
489 [string]$Description,
490
491 [Parameter(Mandatory = $true)]
492 [string]$Version
493 )
494
495 return [ordered]@{
496 name = $CollectionId
497 description = $Description
498 version = $Version
499 }
500}
501
502function New-PluginReadmeContent {
503 <#
504 .SYNOPSIS
505 Generates README.md markdown for a plugin.
506
507 .DESCRIPTION
508 Builds a complete README.md string with a markdownlint-disable header,
509 title, description, install command, and tables for each artifact kind
510 that has items. Only sections with items are included.
511
512 .PARAMETER Collection
513 Hashtable with id, name, and description keys from the collection manifest.
514
515 .PARAMETER Items
516 Array of processed item objects. Each object must have Name, Description,
517 and Kind properties.
518
519 .OUTPUTS
520 [string] Complete README markdown content.
521 #>
522 [CmdletBinding()]
523 [OutputType([string])]
524 param(
525 [Parameter(Mandatory = $true)]
526 [hashtable]$Collection,
527
528 [Parameter(Mandatory = $true)]
529 [AllowEmptyCollection()]
530 [array]$Items
531 )
532
533 $sb = [System.Text.StringBuilder]::new()
534 [void]$sb.AppendLine('<!-- markdownlint-disable-file -->')
535 [void]$sb.AppendLine("# $($Collection.name)")
536 [void]$sb.AppendLine()
537 [void]$sb.AppendLine($Collection.description)
538 [void]$sb.AppendLine()
539 [void]$sb.AppendLine('## Install')
540 [void]$sb.AppendLine()
541 [void]$sb.AppendLine('```bash')
542 [void]$sb.AppendLine("copilot plugin install $($Collection.id)@hve-core")
543 [void]$sb.AppendLine('```')
544
545 $sectionMap = [ordered]@{
546 agent = @{ Title = 'Agents'; Header = 'Agent' }
547 prompt = @{ Title = 'Commands'; Header = 'Command' }
548 instruction = @{ Title = 'Instructions'; Header = 'Instruction' }
549 skill = @{ Title = 'Skills'; Header = 'Skill' }
550 }
551
552 foreach ($entry in $sectionMap.GetEnumerator()) {
553 $kind = $entry.Key
554 $meta = $entry.Value
555 $kindItems = @($Items | Where-Object { $_.Kind -eq $kind })
556 if ($kindItems.Count -eq 0) {
557 continue
558 }
559
560 [void]$sb.AppendLine()
561 [void]$sb.AppendLine("## $($meta.Title)")
562 [void]$sb.AppendLine()
563 [void]$sb.AppendLine("| $($meta.Header) | Description |")
564 [void]$sb.AppendLine('| ' + ('-' * $meta.Header.Length) + ' | ----------- |')
565 foreach ($item in $kindItems) {
566 [void]$sb.AppendLine("| $($item.Name) | $($item.Description) |")
567 }
568 }
569
570 [void]$sb.AppendLine()
571 [void]$sb.AppendLine('---')
572 [void]$sb.AppendLine()
573 [void]$sb.AppendLine('> Source: [microsoft/hve-core](https://github.com/microsoft/hve-core)')
574 [void]$sb.AppendLine()
575
576 return $sb.ToString()
577}
578
579function New-MarketplaceManifestContent {
580 <#
581 .SYNOPSIS
582 Generates marketplace.json content as a hashtable.
583
584 .DESCRIPTION
585 Creates a hashtable representing the marketplace manifest with repository
586 metadata, owner information, and plugin entries. Matches the schema used
587 by github/awesome-copilot.
588
589 .PARAMETER RepoName
590 Repository name used as the marketplace name.
591
592 .PARAMETER Description
593 Short description of the repository.
594
595 .PARAMETER Version
596 Semantic version string from package.json.
597
598 .PARAMETER OwnerName
599 Organization or individual owning the repository.
600
601 .PARAMETER Plugins
602 Array of ordered hashtables with name, description, and version keys
603 from New-PluginManifestContent.
604
605 .OUTPUTS
606 [hashtable] Marketplace manifest with name, metadata, owner, and plugins keys.
607 #>
608 [CmdletBinding()]
609 [OutputType([hashtable])]
610 param(
611 [Parameter(Mandatory = $true)]
612 [string]$RepoName,
613
614 [Parameter(Mandatory = $true)]
615 [string]$Description,
616
617 [Parameter(Mandatory = $true)]
618 [string]$Version,
619
620 [Parameter(Mandatory = $true)]
621 [string]$OwnerName,
622
623 [Parameter(Mandatory = $true)]
624 [AllowEmptyCollection()]
625 [array]$Plugins
626 )
627
628 $pluginEntries = @()
629 foreach ($plugin in $Plugins) {
630 $pluginEntries += [ordered]@{
631 name = $plugin.name
632 source = "./plugins/$($plugin.name)"
633 description = $plugin.description
634 version = $plugin.version
635 }
636 }
637
638 return [ordered]@{
639 name = $RepoName
640 metadata = [ordered]@{
641 description = $Description
642 version = $Version
643 pluginRoot = './plugins'
644 }
645 owner = [ordered]@{
646 name = $OwnerName
647 }
648 plugins = $pluginEntries
649 }
650}
651
652function Write-MarketplaceManifest {
653 <#
654 .SYNOPSIS
655 Writes the marketplace.json file to .github/plugin/.
656
657 .DESCRIPTION
658 Assembles plugin metadata from generated collections and writes the
659 marketplace manifest to .github/plugin/marketplace.json. Creates the
660 directory when it does not exist.
661
662 .PARAMETER RepoRoot
663 Absolute path to the repository root directory.
664
665 .PARAMETER Collections
666 Array of collection manifest hashtables with id and description.
667
668 .PARAMETER DryRun
669 When specified, logs the action without writing to disk.
670 #>
671 [CmdletBinding()]
672 param(
673 [Parameter(Mandatory = $true)]
674 [ValidateNotNullOrEmpty()]
675 [string]$RepoRoot,
676
677 [Parameter(Mandatory = $true)]
678 [AllowEmptyCollection()]
679 [array]$Collections,
680
681 [Parameter(Mandatory = $false)]
682 [switch]$DryRun
683 )
684
685 $packageJsonPath = Join-Path -Path $RepoRoot -ChildPath 'package.json'
686 $packageJson = Get-Content -Path $packageJsonPath -Raw | ConvertFrom-Json
687
688 $plugins = @()
689 foreach ($collection in ($Collections | Sort-Object { $_.id })) {
690 $plugins += New-PluginManifestContent `
691 -CollectionId $collection.id `
692 -Description $collection.description `
693 -Version $packageJson.version
694 }
695
696 $manifest = New-MarketplaceManifestContent `
697 -RepoName $packageJson.name `
698 -Description $packageJson.description `
699 -Version $packageJson.version `
700 -OwnerName $packageJson.author `
701 -Plugins $plugins
702
703 $outputDir = Join-Path -Path $RepoRoot -ChildPath '.github' -AdditionalChildPath 'plugin'
704 $outputPath = Join-Path -Path $outputDir -ChildPath 'marketplace.json'
705
706 if ($DryRun) {
707 Write-Host " [DRY RUN] Would write marketplace.json at $outputPath" -ForegroundColor Yellow
708 return
709 }
710
711 if (-not (Test-Path -Path $outputDir)) {
712 New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
713 }
714
715 $manifest | ConvertTo-Json -Depth 10 | Set-Content -Path $outputPath -Encoding utf8 -NoNewline
716 Write-Host " Marketplace manifest: $outputPath" -ForegroundColor Green
717}
718
719function New-GenerateResult {
720 <#
721 .SYNOPSIS
722 Creates a standardized result object.
723
724 .DESCRIPTION
725 Returns a hashtable representing the outcome of a plugin generation run
726 with success status, plugin count, and optional error message.
727
728 .PARAMETER Success
729 Whether the operation succeeded.
730
731 .PARAMETER PluginCount
732 Number of plugins generated.
733
734 .PARAMETER ErrorMessage
735 Optional error message when Success is $false.
736
737 .OUTPUTS
738 [hashtable] Result with Success, PluginCount, and ErrorMessage keys.
739 #>
740 [CmdletBinding()]
741 [OutputType([hashtable])]
742 param(
743 [Parameter(Mandatory = $true)]
744 [bool]$Success,
745
746 [Parameter(Mandatory = $true)]
747 [int]$PluginCount,
748
749 [Parameter(Mandatory = $false)]
750 [string]$ErrorMessage = ''
751 )
752
753 return @{
754 Success = $Success
755 PluginCount = $PluginCount
756 ErrorMessage = $ErrorMessage
757 }
758}
759
760# ---------------------------------------------------------------------------
761# I/O Functions (file system operations)
762# ---------------------------------------------------------------------------
763
764function New-RelativeSymlink {
765 <#
766 .SYNOPSIS
767 Creates a relative symlink from destination to source.
768
769 .DESCRIPTION
770 Calculates the relative path from the directory containing the destination
771 to the source path, then creates a symbolic link at the destination
772 pointing to that relative path.
773
774 .PARAMETER SourcePath
775 Absolute path to the symlink target (the real file or directory).
776
777 .PARAMETER DestinationPath
778 Absolute path where the symlink will be created.
779 #>
780 [CmdletBinding()]
781 param(
782 [Parameter(Mandatory = $true)]
783 [string]$SourcePath,
784
785 [Parameter(Mandatory = $true)]
786 [string]$DestinationPath
787 )
788
789 $destinationDir = Split-Path -Parent $DestinationPath
790 $relativePath = [System.IO.Path]::GetRelativePath($destinationDir, $SourcePath)
791
792 if (-not (Test-Path -Path $destinationDir)) {
793 New-Item -ItemType Directory -Path $destinationDir -Force | Out-Null
794 }
795
796 New-Item -ItemType SymbolicLink -Path $DestinationPath -Value $relativePath -Force | Out-Null
797}
798
799function Write-PluginDirectory {
800 <#
801 .SYNOPSIS
802 Creates a complete plugin directory structure from a collection.
803
804 .DESCRIPTION
805 Builds the full plugin layout under the specified plugins directory,
806 including subdirectories for agents, commands, instructions, and skills.
807 Each item is symlinked from the plugin directory back to its source in
808 the repository. Generates plugin.json and README.md.
809
810 .PARAMETER Collection
811 Parsed collection manifest hashtable with id, name, description, and items.
812
813 .PARAMETER PluginsDir
814 Absolute path to the root plugins output directory.
815
816 .PARAMETER RepoRoot
817 Absolute path to the repository root.
818
819 .PARAMETER Version
820 Semantic version string from the repository package.json.
821
822 .PARAMETER DryRun
823 When specified, logs actions without creating files or directories.
824
825 .OUTPUTS
826 [hashtable] Result with Success, AgentCount, CommandCount, InstructionCount,
827 and SkillCount keys.
828 #>
829 [CmdletBinding()]
830 [OutputType([hashtable])]
831 param(
832 [Parameter(Mandatory = $true)]
833 [hashtable]$Collection,
834
835 [Parameter(Mandatory = $true)]
836 [string]$PluginsDir,
837
838 [Parameter(Mandatory = $true)]
839 [string]$RepoRoot,
840
841 [Parameter(Mandatory = $true)]
842 [string]$Version,
843
844 [Parameter(Mandatory = $false)]
845 [switch]$DryRun
846 )
847
848 $collectionId = $Collection.id
849 $pluginRoot = Join-Path -Path $PluginsDir -ChildPath $collectionId
850
851 $counts = @{
852 AgentCount = 0
853 CommandCount = 0
854 InstructionCount = 0
855 SkillCount = 0
856 }
857
858 $readmeItems = @()
859
860 foreach ($item in $Collection.items) {
861 $kind = $item.kind
862 $sourcePath = Join-Path -Path $RepoRoot -ChildPath $item.path
863 $subdir = Get-PluginSubdirectory -Kind $kind
864
865 if ($kind -eq 'skill') {
866 # Skills are directory symlinks; use the directory name as FileName
867 $fileName = Split-Path -Leaf $item.path
868 $itemName = Get-PluginItemName -FileName $fileName -Kind $kind
869 $destPath = Join-Path -Path $pluginRoot -ChildPath $subdir -AdditionalChildPath $itemName
870 $description = $fileName
871 }
872 else {
873 $fileName = Split-Path -Leaf $item.path
874 $itemName = Get-PluginItemName -FileName $fileName -Kind $kind
875 $destPath = Join-Path -Path $pluginRoot -ChildPath $subdir -AdditionalChildPath $itemName
876
877 # Read frontmatter from the source file for description
878 $fallback = $itemName -replace '\.md$', ''
879 if (Test-Path -Path $sourcePath) {
880 $frontmatter = Get-ArtifactFrontmatter -FilePath $sourcePath -FallbackDescription $fallback
881 $description = $frontmatter.description
882 }
883 else {
884 $description = $fallback
885 Write-Warning "Source file not found: $sourcePath"
886 }
887 }
888
889 $readmeItems += @{
890 Name = $itemName -replace '\.md$', ''
891 Description = $description
892 Kind = $kind
893 }
894
895 # Update counts
896 switch ($kind) {
897 'agent' { $counts.AgentCount++ }
898 'prompt' { $counts.CommandCount++ }
899 'instruction' { $counts.InstructionCount++ }
900 'skill' { $counts.SkillCount++ }
901 }
902
903 if ($DryRun) {
904 Write-Verbose "DryRun: Would create symlink $destPath -> $sourcePath"
905 continue
906 }
907
908 New-RelativeSymlink -SourcePath $sourcePath -DestinationPath $destPath
909 }
910
911 # Symlink shared resource directories (unconditional, all plugins)
912 $sharedDirs = @(
913 @{ Source = 'docs/templates'; Destination = 'docs/templates' }
914 @{ Source = 'scripts/dev-tools'; Destination = 'scripts/dev-tools' }
915 @{ Source = 'scripts/lib'; Destination = 'scripts/lib' }
916 )
917
918 foreach ($dir in $sharedDirs) {
919 $sourcePath = Join-Path -Path $RepoRoot -ChildPath $dir.Source
920 $destPath = Join-Path -Path $pluginRoot -ChildPath $dir.Destination
921
922 if (-not (Test-Path -Path $sourcePath)) {
923 Write-Warning "Shared directory not found: $sourcePath"
924 continue
925 }
926
927 if ($DryRun) {
928 Write-Verbose "DryRun: Would create shared directory symlink $destPath -> $sourcePath"
929 continue
930 }
931
932 New-RelativeSymlink -SourcePath $sourcePath -DestinationPath $destPath
933 }
934
935 # Generate plugin.json
936 $manifestDir = Join-Path -Path $pluginRoot -ChildPath '.github' -AdditionalChildPath 'plugin'
937 $manifestPath = Join-Path -Path $manifestDir -ChildPath 'plugin.json'
938 $manifest = New-PluginManifestContent -CollectionId $collectionId -Description $Collection.description -Version $Version
939
940 if ($DryRun) {
941 Write-Verbose "DryRun: Would write plugin.json at $manifestPath"
942 }
943 else {
944 if (-not (Test-Path -Path $manifestDir)) {
945 New-Item -ItemType Directory -Path $manifestDir -Force | Out-Null
946 }
947 $manifest | ConvertTo-Json -Depth 10 | Set-Content -Path $manifestPath -Encoding utf8 -NoNewline
948 }
949
950 # Generate README.md
951 $readmePath = Join-Path -Path $pluginRoot -ChildPath 'README.md'
952 $readmeContent = New-PluginReadmeContent -Collection $Collection -Items $readmeItems
953
954 if ($DryRun) {
955 Write-Verbose "DryRun: Would write README.md at $readmePath"
956 }
957 else {
958 Set-Content -Path $readmePath -Value $readmeContent -Encoding utf8 -NoNewline
959 }
960
961 return @{
962 Success = $true
963 AgentCount = $counts.AgentCount
964 CommandCount = $counts.CommandCount
965 InstructionCount = $counts.InstructionCount
966 SkillCount = $counts.SkillCount
967 }
968}
969
970Export-ModuleMember -Function @(
971 'Get-AllCollections',
972 'Get-ArtifactFiles',
973 'Get-ArtifactFrontmatter',
974 'Get-CollectionManifest',
975 'Get-PluginItemName',
976 'Get-PluginSubdirectory',
977 'New-GenerateResult',
978 'New-MarketplaceManifestContent',
979 'New-PluginManifestContent',
980 'New-PluginReadmeContent',
981 'New-RelativeSymlink',
982 'Resolve-CollectionItemMaturity',
983 'Test-ArtifactDeprecated',
984 'Update-HveCoreAllCollection',
985 'Write-MarketplaceManifest',
986 'Write-PluginDirectory'
987)
988