microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
e69486a5f809ede45c63c0a31358c12912bd5168

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/plugins/Modules/PluginHelpers.psm1

880lines · 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
11Import-Module (Join-Path $PSScriptRoot '../../collections/Modules/CollectionHelpers.psm1') -Force
12
13# ---------------------------------------------------------------------------
14# Pure Functions (no file system side effects)
15# ---------------------------------------------------------------------------
16
17function Get-PluginItemName {
18 <#
19 .SYNOPSIS
20 Strips artifact-type suffix from a filename.
21
22 .DESCRIPTION
23 Removes the kind-specific suffix from a filename and returns the
24 simplified name with a .md extension (or the directory name for skills).
25
26 .PARAMETER FileName
27 The original filename (e.g. task-researcher.agent.md).
28
29 .PARAMETER Kind
30 The artifact kind: agent, prompt, instruction, or skill.
31
32 .OUTPUTS
33 [string] The simplified item name.
34 #>
35 [CmdletBinding()]
36 [OutputType([string])]
37 param(
38 [Parameter(Mandatory = $true)]
39 [string]$FileName,
40
41 [Parameter(Mandatory = $true)]
42 [ValidateSet('agent', 'prompt', 'instruction', 'skill')]
43 [string]$Kind
44 )
45
46 switch ($Kind) {
47 'agent' {
48 return ($FileName -replace '\.agent\.md$', '') + '.md'
49 }
50 'prompt' {
51 return ($FileName -replace '\.prompt\.md$', '') + '.md'
52 }
53 'instruction' {
54 return ($FileName -replace '\.instructions\.md$', '') + '.md'
55 }
56 'skill' {
57 return $FileName
58 }
59 }
60}
61
62function Get-PluginSubdirectory {
63 <#
64 .SYNOPSIS
65 Returns the plugin subdirectory name for an artifact kind.
66
67 .DESCRIPTION
68 Maps a collection item kind to the corresponding subdirectory name
69 within the plugin directory structure.
70
71 .PARAMETER Kind
72 The artifact kind: agent, prompt, instruction, or skill.
73
74 .OUTPUTS
75 [string] The subdirectory name (agents, commands, instructions, or skills).
76 #>
77 [CmdletBinding()]
78 [OutputType([string])]
79 param(
80 [Parameter(Mandatory = $true)]
81 [ValidateSet('agent', 'prompt', 'instruction', 'skill')]
82 [string]$Kind
83 )
84
85 switch ($Kind) {
86 'agent' { return 'agents' }
87 'prompt' { return 'commands' }
88 'instruction' { return 'instructions' }
89 'skill' { return 'skills' }
90 }
91}
92
93function New-PluginManifestContent {
94 <#
95 .SYNOPSIS
96 Generates plugin.json content as a hashtable.
97
98 .DESCRIPTION
99 Creates a hashtable representing the plugin manifest with name,
100 description, and version sourced from the repository package.json.
101
102 .PARAMETER CollectionId
103 The collection identifier used as the plugin name.
104
105 .PARAMETER Description
106 A short description of the plugin.
107
108 .PARAMETER Version
109 Semantic version string from the repository package.json.
110
111 .OUTPUTS
112 [hashtable] Plugin manifest with name, description, and version keys.
113 #>
114 [CmdletBinding()]
115 [OutputType([hashtable])]
116 param(
117 [Parameter(Mandatory = $true)]
118 [string]$CollectionId,
119
120 [Parameter(Mandatory = $true)]
121 [string]$Description,
122
123 [Parameter(Mandatory = $true)]
124 [string]$Version
125 )
126
127 return [ordered]@{
128 name = $CollectionId
129 description = $Description
130 version = $Version
131 }
132}
133
134function New-PluginReadmeContent {
135 <#
136 .SYNOPSIS
137 Generates README.md markdown for a plugin.
138
139 .DESCRIPTION
140 Builds a complete README.md string with a markdownlint-disable header,
141 title, description, install command, and tables for each artifact kind
142 that has items. Only sections with items are included.
143
144 .PARAMETER Collection
145 Hashtable with id, name, and description keys from the collection manifest.
146 An optional 'notice' key injects a custom blockquote after the description.
147
148 .PARAMETER Items
149 Array of processed item objects. Each object must have Name, Description,
150 and Kind properties.
151
152 .PARAMETER Maturity
153 Optional collection-level maturity string. When 'experimental', an
154 experimental notice is injected after the description. When 'preview',
155 a preview notice is injected.
156
157 .PARAMETER CollectionContent
158 Optional markdown content from the collection .md file. Injected as
159 an Overview section between the description and the Install section.
160
161 .OUTPUTS
162 [string] Complete README markdown content.
163 #>
164 [CmdletBinding()]
165 [OutputType([string])]
166 param(
167 [Parameter(Mandatory = $true)]
168 [hashtable]$Collection,
169
170 [Parameter(Mandatory = $true)]
171 [AllowEmptyCollection()]
172 [array]$Items,
173
174 [Parameter(Mandatory = $false)]
175 [AllowNull()]
176 [AllowEmptyString()]
177 [string]$Maturity,
178
179 [Parameter(Mandatory = $false)]
180 [AllowNull()]
181 [AllowEmptyString()]
182 [string]$CollectionContent
183 )
184
185 $sb = [System.Text.StringBuilder]::new()
186 [void]$sb.AppendLine('<!-- markdownlint-disable-file -->')
187 [void]$sb.AppendLine("# $($Collection.name)")
188 [void]$sb.AppendLine()
189 [void]$sb.AppendLine($Collection.description)
190
191 # Inject maturity notice when collection is not stable
192 $effectiveMaturity = if ([string]::IsNullOrWhiteSpace($Maturity)) { 'stable' } else { $Maturity }
193 if ($effectiveMaturity -eq 'experimental') {
194 [void]$sb.AppendLine()
195 [void]$sb.AppendLine("> **`u{26A0}`u{FE0F} Experimental** `u{2014} This collection is experimental. Contents and behavior may change or be removed without notice.")
196 }
197 elseif ($effectiveMaturity -eq 'preview') {
198 [void]$sb.AppendLine()
199 [void]$sb.AppendLine("> **`u{1F50D} Preview** `u{2014} This collection is in preview. Core features are complete and functional but refinements may follow.")
200 }
201
202 # Inject collection-level notice when present
203 if ($Collection.ContainsKey('notice') -and -not [string]::IsNullOrWhiteSpace($Collection.notice)) {
204 [void]$sb.AppendLine()
205 [void]$sb.AppendLine($Collection.notice.TrimEnd())
206 }
207
208 # Inject collection description content as an Overview section
209 if (-not [string]::IsNullOrWhiteSpace($CollectionContent)) {
210 [void]$sb.AppendLine()
211 [void]$sb.AppendLine('## Overview')
212 [void]$sb.AppendLine()
213 [void]$sb.AppendLine($CollectionContent.TrimEnd())
214 }
215
216 [void]$sb.AppendLine()
217 [void]$sb.AppendLine('## Install')
218 [void]$sb.AppendLine()
219 [void]$sb.AppendLine('```bash')
220 [void]$sb.AppendLine("copilot plugin install $($Collection.id)@hve-core")
221 [void]$sb.AppendLine('```')
222
223 $sectionMap = [ordered]@{
224 agent = @{ Title = 'Agents'; Header = 'Agent' }
225 prompt = @{ Title = 'Commands'; Header = 'Command' }
226 instruction = @{ Title = 'Instructions'; Header = 'Instruction' }
227 skill = @{ Title = 'Skills'; Header = 'Skill' }
228 }
229
230 foreach ($entry in $sectionMap.GetEnumerator()) {
231 $kind = $entry.Key
232 $meta = $entry.Value
233 $kindItems = @($Items | Where-Object { $_.Kind -eq $kind })
234 if ($kindItems.Count -eq 0) {
235 continue
236 }
237
238 [void]$sb.AppendLine()
239 [void]$sb.AppendLine("## $($meta.Title)")
240 [void]$sb.AppendLine()
241
242 # Calculate column widths for aligned table output
243 $col1Width = $meta.Header.Length
244 $col2Width = 'Description'.Length
245 foreach ($item in $kindItems) {
246 if ($item.Name.Length -gt $col1Width) { $col1Width = $item.Name.Length }
247 if ($item.Description.Length -gt $col2Width) { $col2Width = $item.Description.Length }
248 }
249
250 [void]$sb.AppendLine("| $($meta.Header.PadRight($col1Width)) | $('Description'.PadRight($col2Width)) |")
251 [void]$sb.AppendLine('|' + ('-' * ($col1Width + 2)) + '|' + ('-' * ($col2Width + 2)) + '|')
252 foreach ($item in $kindItems) {
253 [void]$sb.AppendLine("| $($item.Name.PadRight($col1Width)) | $($item.Description.PadRight($col2Width)) |")
254 }
255 }
256
257 [void]$sb.AppendLine()
258 [void]$sb.AppendLine('---')
259 [void]$sb.AppendLine()
260 [void]$sb.AppendLine('> Source: [microsoft/hve-core](https://github.com/microsoft/hve-core)')
261 [void]$sb.AppendLine()
262
263 return $sb.ToString()
264}
265
266function New-MarketplaceManifestContent {
267 <#
268 .SYNOPSIS
269 Generates marketplace.json content as a hashtable.
270
271 .DESCRIPTION
272 Creates a hashtable representing the marketplace manifest with repository
273 metadata, owner information, and plugin entries. Matches the schema used
274 by github/awesome-copilot.
275
276 .PARAMETER RepoName
277 Repository name used as the marketplace name.
278
279 .PARAMETER Description
280 Short description of the repository.
281
282 .PARAMETER Version
283 Semantic version string from package.json.
284
285 .PARAMETER OwnerName
286 Organization or individual owning the repository.
287
288 .PARAMETER Plugins
289 Array of ordered hashtables with name, description, and version keys
290 from New-PluginManifestContent.
291
292 .OUTPUTS
293 [hashtable] Marketplace manifest with name, metadata, owner, and plugins keys.
294 #>
295 [CmdletBinding()]
296 [OutputType([hashtable])]
297 param(
298 [Parameter(Mandatory = $true)]
299 [string]$RepoName,
300
301 [Parameter(Mandatory = $true)]
302 [string]$Description,
303
304 [Parameter(Mandatory = $true)]
305 [string]$Version,
306
307 [Parameter(Mandatory = $true)]
308 [string]$OwnerName,
309
310 [Parameter(Mandatory = $true)]
311 [AllowEmptyCollection()]
312 [array]$Plugins
313 )
314
315 $pluginEntries = @()
316 foreach ($plugin in $Plugins) {
317 $pluginEntries += [ordered]@{
318 name = $plugin.name
319 source = $plugin.name
320 description = $plugin.description
321 version = $plugin.version
322 }
323 }
324
325 return [ordered]@{
326 name = $RepoName
327 metadata = [ordered]@{
328 description = $Description
329 version = $Version
330 pluginRoot = './plugins'
331 }
332 owner = [ordered]@{
333 name = $OwnerName
334 }
335 plugins = $pluginEntries
336 }
337}
338
339function Write-MarketplaceManifest {
340 <#
341 .SYNOPSIS
342 Writes the marketplace.json file to .github/plugin/.
343
344 .DESCRIPTION
345 Assembles plugin metadata from generated collections and writes the
346 marketplace manifest to .github/plugin/marketplace.json. Creates the
347 directory when it does not exist.
348
349 .PARAMETER RepoRoot
350 Absolute path to the repository root directory.
351
352 .PARAMETER Collections
353 Array of collection manifest hashtables with id and description.
354
355 .PARAMETER DryRun
356 When specified, logs the action without writing to disk.
357 #>
358 [CmdletBinding()]
359 param(
360 [Parameter(Mandatory = $true)]
361 [ValidateNotNullOrEmpty()]
362 [string]$RepoRoot,
363
364 [Parameter(Mandatory = $true)]
365 [AllowEmptyCollection()]
366 [array]$Collections,
367
368 [Parameter(Mandatory = $false)]
369 [switch]$DryRun
370 )
371
372 $packageJsonPath = Join-Path -Path $RepoRoot -ChildPath 'package.json'
373 $packageJson = Get-Content -Path $packageJsonPath -Raw | ConvertFrom-Json
374
375 $plugins = @()
376 foreach ($collection in ($Collections | Sort-Object { $_.id })) {
377 $plugins += New-PluginManifestContent `
378 -CollectionId $collection.id `
379 -Description $collection.description `
380 -Version $packageJson.version
381 }
382
383 $manifest = New-MarketplaceManifestContent `
384 -RepoName $packageJson.name `
385 -Description $packageJson.description `
386 -Version $packageJson.version `
387 -OwnerName $packageJson.author `
388 -Plugins $plugins
389
390 $outputDir = Join-Path -Path $RepoRoot -ChildPath '.github' -AdditionalChildPath 'plugin'
391 $outputPath = Join-Path -Path $outputDir -ChildPath 'marketplace.json'
392
393 if ($DryRun) {
394 Write-Host " [DRY RUN] Would write marketplace.json at $outputPath" -ForegroundColor Yellow
395 return
396 }
397
398 if (-not (Test-Path -Path $outputDir)) {
399 New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
400 }
401
402 $manifestJson = $manifest | ConvertTo-Json -Depth 10
403 Set-ContentIfChanged -Path $outputPath -Value $manifestJson | Out-Null
404 Write-Host " Marketplace manifest: $outputPath" -ForegroundColor Green
405}
406
407function New-GenerateResult {
408 <#
409 .SYNOPSIS
410 Creates a standardized result object.
411
412 .DESCRIPTION
413 Returns a hashtable representing the outcome of a plugin generation run
414 with success status, plugin count, and optional error message.
415
416 .PARAMETER Success
417 Whether the operation succeeded.
418
419 .PARAMETER PluginCount
420 Number of plugins generated.
421
422 .PARAMETER ErrorMessage
423 Optional error message when Success is $false.
424
425 .OUTPUTS
426 [hashtable] Result with Success, PluginCount, and ErrorMessage keys.
427 #>
428 [CmdletBinding()]
429 [OutputType([hashtable])]
430 param(
431 [Parameter(Mandatory = $true)]
432 [bool]$Success,
433
434 [Parameter(Mandatory = $true)]
435 [int]$PluginCount,
436
437 [Parameter(Mandatory = $false)]
438 [string]$ErrorMessage = ''
439 )
440
441 return @{
442 Success = $Success
443 PluginCount = $PluginCount
444 ErrorMessage = $ErrorMessage
445 }
446}
447
448# ---------------------------------------------------------------------------
449# I/O Functions (file system operations)
450# ---------------------------------------------------------------------------
451
452function Test-SymlinkCapability {
453 <#
454 .SYNOPSIS
455 Probes whether the current process can create symbolic links.
456
457 .DESCRIPTION
458 Creates a temporary file and attempts to symlink to it. Returns $true
459 when the OS and process privileges allow symlink creation, $false
460 otherwise. The probe directory is cleaned up unconditionally.
461 #>
462 [CmdletBinding()]
463 [OutputType([bool])]
464 param()
465
466 $tempDir = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "hve-symlink-probe-$PID"
467 $targetFile = Join-Path -Path $tempDir -ChildPath 'target.txt'
468 $linkFile = Join-Path -Path $tempDir -ChildPath 'link.txt'
469 try {
470 New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
471 Set-Content -Path $targetFile -Value 'probe' -NoNewline
472 New-Item -ItemType SymbolicLink -Path $linkFile -Target $targetFile -ErrorAction Stop | Out-Null
473 return $true
474 }
475 catch {
476 return $false
477 }
478 finally {
479 if (Test-Path -Path $tempDir) {
480 Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
481 }
482 }
483}
484
485function New-PluginLink {
486 <#
487 .SYNOPSIS
488 Links a source path into a plugin destination via symlink or text stub.
489
490 .DESCRIPTION
491 When SymlinkCapable is set, creates a relative symbolic link from
492 DestinationPath to SourcePath. Otherwise writes a text stub file
493 containing the relative path, matching the format git produces when
494 core.symlinks is false. Text stubs keep git status clean on Windows
495 without Developer Mode or elevated privileges.
496
497 .PARAMETER SourcePath
498 Absolute path to the real file or directory.
499
500 .PARAMETER DestinationPath
501 Absolute path where the link or text stub will be created.
502
503 .PARAMETER SymlinkCapable
504 When set, create a symbolic link; otherwise write a text stub.
505 #>
506 [CmdletBinding()]
507 param(
508 [Parameter(Mandatory = $true)]
509 [string]$SourcePath,
510
511 [Parameter(Mandatory = $true)]
512 [string]$DestinationPath,
513
514 [Parameter(Mandatory = $false)]
515 [switch]$SymlinkCapable
516 )
517
518 $destinationDir = Split-Path -Parent $DestinationPath
519 if (-not (Test-Path -Path $destinationDir)) {
520 New-Item -ItemType Directory -Path $destinationDir -Force | Out-Null
521 }
522
523 $relativePath = [System.IO.Path]::GetRelativePath($destinationDir, $SourcePath) -replace '\\', '/'
524
525 if ($SymlinkCapable) {
526 New-Item -ItemType SymbolicLink -Path $DestinationPath -Value $relativePath -Force | Out-Null
527 }
528 else {
529 Set-ContentIfChanged -Path $DestinationPath -Value $relativePath | Out-Null
530 }
531}
532
533function Write-PluginDirectory {
534 <#
535 .SYNOPSIS
536 Creates a complete plugin directory structure from a collection.
537
538 .DESCRIPTION
539 Builds the full plugin layout under the specified plugins directory,
540 including subdirectories for agents, commands, instructions, and skills.
541 Each item is linked or copied from the plugin directory back to its
542 source in the repository. Generates plugin.json and README.md.
543
544 .PARAMETER Collection
545 Parsed collection manifest hashtable with id, name, description, and items.
546
547 .PARAMETER PluginsDir
548 Absolute path to the root plugins output directory.
549
550 .PARAMETER RepoRoot
551 Absolute path to the repository root.
552
553 .PARAMETER Version
554 Semantic version string from the repository package.json.
555
556 .PARAMETER Maturity
557 Optional collection-level maturity string. Forwarded to
558 New-PluginReadmeContent for maturity notice injection.
559
560 .PARAMETER DryRun
561 When specified, logs actions without creating files or directories.
562
563 .PARAMETER SymlinkCapable
564 When specified, creates symbolic links; otherwise copies files.
565
566 .OUTPUTS
567 [hashtable] Result with Success, AgentCount, CommandCount, InstructionCount,
568 and SkillCount keys.
569 #>
570 [CmdletBinding()]
571 [OutputType([hashtable])]
572 param(
573 [Parameter(Mandatory = $true)]
574 [hashtable]$Collection,
575
576 [Parameter(Mandatory = $true)]
577 [string]$PluginsDir,
578
579 [Parameter(Mandatory = $true)]
580 [string]$RepoRoot,
581
582 [Parameter(Mandatory = $true)]
583 [string]$Version,
584
585 [Parameter(Mandatory = $false)]
586 [AllowNull()]
587 [AllowEmptyString()]
588 [string]$Maturity,
589
590 [Parameter(Mandatory = $false)]
591 [switch]$DryRun,
592
593 [Parameter(Mandatory = $false)]
594 [switch]$SymlinkCapable
595 )
596
597 $collectionId = $Collection.id
598 $pluginRoot = Join-Path -Path $PluginsDir -ChildPath $collectionId
599
600 $counts = @{
601 AgentCount = 0
602 CommandCount = 0
603 InstructionCount = 0
604 SkillCount = 0
605 }
606
607 $readmeItems = @()
608 $generatedFiles = [System.Collections.Generic.HashSet[string]]::new(
609 [System.StringComparer]::OrdinalIgnoreCase
610 )
611
612 foreach ($item in $Collection.items) {
613 $kind = $item.kind
614 $sourcePath = Join-Path -Path $RepoRoot -ChildPath $item.path
615 $subdir = Get-PluginSubdirectory -Kind $kind
616
617 if ($kind -eq 'skill') {
618 # Skills are directory symlinks; use the directory name as FileName
619 $fileName = Split-Path -Leaf $item.path
620 $itemName = Get-PluginItemName -FileName $fileName -Kind $kind
621 $destPath = Join-Path -Path $pluginRoot -ChildPath $subdir -AdditionalChildPath $itemName
622
623 # Read frontmatter from SKILL.md for description; fall back to directory name
624 $skillMdPath = Join-Path -Path $sourcePath -ChildPath 'SKILL.md'
625 if (Test-Path -Path $skillMdPath) {
626 $frontmatter = Get-ArtifactFrontmatter -FilePath $skillMdPath -FallbackDescription $fileName
627 $description = $frontmatter.description
628 }
629 else {
630 $description = $fileName
631 }
632 }
633 else {
634 $fileName = Split-Path -Leaf $item.path
635 $itemName = Get-PluginItemName -FileName $fileName -Kind $kind
636 $destPath = Join-Path -Path $pluginRoot -ChildPath $subdir -AdditionalChildPath $itemName
637
638 # Read frontmatter from the source file for description
639 $fallback = $itemName -replace '\.md$', ''
640 if (Test-Path -Path $sourcePath) {
641 $frontmatter = Get-ArtifactFrontmatter -FilePath $sourcePath -FallbackDescription $fallback
642 $description = $frontmatter.description
643 }
644 else {
645 $description = $fallback
646 Write-Warning "Source file not found: $sourcePath"
647 }
648 }
649
650 $readmeItems += @{
651 Name = $itemName -replace '\.md$', ''
652 Description = $description
653 Kind = $kind
654 }
655
656 # Update counts
657 switch ($kind) {
658 'agent' { $counts.AgentCount++ }
659 'prompt' { $counts.CommandCount++ }
660 'instruction' { $counts.InstructionCount++ }
661 'skill' { $counts.SkillCount++ }
662 }
663
664 [void]$generatedFiles.Add($destPath)
665
666 if ($DryRun) {
667 Write-Verbose "DryRun: Would create link $destPath -> $sourcePath"
668 continue
669 }
670
671 New-PluginLink -SourcePath $sourcePath -DestinationPath $destPath -SymlinkCapable:$SymlinkCapable
672 }
673
674 # Link shared resource directories (unconditional, all plugins)
675 $sharedDirs = @(
676 @{ Source = 'docs/templates'; Destination = 'docs/templates' }
677 @{ Source = 'scripts/lib'; Destination = 'scripts/lib' }
678 )
679
680 foreach ($dir in $sharedDirs) {
681 $sourcePath = Join-Path -Path $RepoRoot -ChildPath $dir.Source
682 $destPath = Join-Path -Path $pluginRoot -ChildPath $dir.Destination
683
684 if (-not (Test-Path -Path $sourcePath)) {
685 Write-Warning "Shared directory not found: $sourcePath"
686 continue
687 }
688
689 [void]$generatedFiles.Add($destPath)
690
691 if ($DryRun) {
692 Write-Verbose "DryRun: Would create shared directory link $destPath -> $sourcePath"
693 continue
694 }
695
696 New-PluginLink -SourcePath $sourcePath -DestinationPath $destPath -SymlinkCapable:$SymlinkCapable
697 }
698
699 # Generate plugin.json
700 $manifestDir = Join-Path -Path $pluginRoot -ChildPath '.github' -AdditionalChildPath 'plugin'
701 $manifestPath = Join-Path -Path $manifestDir -ChildPath 'plugin.json'
702 $manifest = New-PluginManifestContent -CollectionId $collectionId -Description $Collection.description -Version $Version
703 [void]$generatedFiles.Add($manifestPath)
704
705 if ($DryRun) {
706 Write-Verbose "DryRun: Would write plugin.json at $manifestPath"
707 }
708 else {
709 if (-not (Test-Path -Path $manifestDir)) {
710 New-Item -ItemType Directory -Path $manifestDir -Force | Out-Null
711 }
712 $jsonContent = $manifest | ConvertTo-Json -Depth 10
713 Set-ContentIfChanged -Path $manifestPath -Value $jsonContent | Out-Null
714 }
715
716 # Generate README.md
717 $readmePath = Join-Path -Path $pluginRoot -ChildPath 'README.md'
718 $collectionMdPath = Join-Path -Path $RepoRoot -ChildPath "collections/$collectionId.collection.md"
719 $collectionContent = if (Test-Path -Path $collectionMdPath) {
720 Get-Content -Path $collectionMdPath -Raw
721 } else { $null }
722 $readmeContent = New-PluginReadmeContent -Collection $Collection -Items $readmeItems -Maturity $Maturity -CollectionContent $collectionContent
723 [void]$generatedFiles.Add($readmePath)
724
725 if ($DryRun) {
726 Write-Verbose "DryRun: Would write README.md at $readmePath"
727 }
728 else {
729 Set-ContentIfChanged -Path $readmePath -Value $readmeContent | Out-Null
730 }
731
732 return @{
733 Success = $true
734 AgentCount = $counts.AgentCount
735 CommandCount = $counts.CommandCount
736 InstructionCount = $counts.InstructionCount
737 SkillCount = $counts.SkillCount
738 GeneratedFiles = $generatedFiles
739 }
740}
741
742function Repair-PluginSymlinkIndex {
743 <#
744 .SYNOPSIS
745 Fixes git index modes for text stub files so they register as symlinks.
746
747 .DESCRIPTION
748 On systems where symlinks are unavailable (Windows without Developer Mode),
749 New-PluginLink writes text stubs containing relative paths. Git stages
750 these as mode 100644 (regular file). This function re-indexes each text
751 stub as mode 120000 (symlink) so that Linux/macOS checkouts materialize
752 real symbolic links.
753
754 .PARAMETER PluginsDir
755 Absolute path to the plugins output directory.
756
757 .PARAMETER RepoRoot
758 Absolute path to the repository root (git working tree).
759
760 .PARAMETER DryRun
761 When specified, logs what would be fixed without modifying the index.
762
763 .OUTPUTS
764 [int] Number of index entries corrected.
765 #>
766 [CmdletBinding()]
767 [OutputType([int])]
768 param(
769 [Parameter(Mandatory = $true)]
770 [ValidateNotNullOrEmpty()]
771 [string]$PluginsDir,
772
773 [Parameter(Mandatory = $true)]
774 [ValidateNotNullOrEmpty()]
775 [string]$RepoRoot,
776
777 [Parameter(Mandatory = $false)]
778 [switch]$DryRun
779 )
780
781 if (-not (Test-Path -Path $PluginsDir)) {
782 return 0
783 }
784
785 # Build a set of paths already tracked in the git index under plugins/.
786 # --index-info silently ignores untracked paths (PowerShell pipe encoding
787 # issue), so new files must be added individually via --cacheinfo.
788 $trackedPaths = [System.Collections.Generic.HashSet[string]]::new(
789 [System.StringComparer]::OrdinalIgnoreCase
790 )
791 $alreadySymlink = [System.Collections.Generic.HashSet[string]]::new(
792 [System.StringComparer]::OrdinalIgnoreCase
793 )
794 $pluginsRel = [System.IO.Path]::GetRelativePath($RepoRoot, $PluginsDir) -replace '\\', '/'
795 $lsOutput = git ls-files --stage -- $pluginsRel 2>$null
796 if ($lsOutput) {
797 foreach ($line in @($lsOutput)) {
798 if ($line -match '^(\d+)\s+[0-9a-f]+\s+\d+\t(.+)$') {
799 [void]$trackedPaths.Add($Matches[2])
800 if ($Matches[1] -eq '120000') {
801 [void]$alreadySymlink.Add($Matches[2])
802 }
803 }
804 }
805 }
806
807 $fixedCount = 0
808 $files = Get-ChildItem -Path $PluginsDir -File -Recurse
809
810 foreach ($file in $files) {
811 # Text stubs are small files whose content is a relative path with
812 # forward slashes, no line breaks, starting with ../
813 if ($file.Length -gt 500) {
814 continue
815 }
816
817 $content = [System.IO.File]::ReadAllText($file.FullName)
818
819 if ($content -notmatch '^\.\./') {
820 continue
821 }
822 if ($content.Contains("`n") -or $content.Contains("`r")) {
823 continue
824 }
825
826 $repoRelPath = [System.IO.Path]::GetRelativePath($RepoRoot, $file.FullName) -replace '\\', '/'
827
828 if ($alreadySymlink.Contains($repoRelPath)) {
829 continue
830 }
831
832 if ($DryRun) {
833 Write-Verbose "DryRun: Would fix index mode for $repoRelPath"
834 $fixedCount++
835 continue
836 }
837
838 $hashOutput = git hash-object -w -- $file.FullName 2>&1
839 if ($LASTEXITCODE -ne 0) {
840 Write-Warning "Failed to hash-object for $repoRelPath"
841 continue
842 }
843
844 # Extract clean SHA string, filtering out any ErrorRecord objects
845 $sha = @($hashOutput | Where-Object { $_ -is [string] -and $_ -match '^[0-9a-f]{40}' })[0]
846 if (-not $sha) {
847 Write-Warning "No valid SHA returned for $repoRelPath"
848 continue
849 }
850
851 # Use --add for untracked files; harmless for already-tracked entries.
852 # Avoids --index-info piping which breaks on Windows due to CRLF stdin.
853 $addFlag = if (-not $trackedPaths.Contains($repoRelPath)) { '--add' } else { $null }
854 $cacheArgs = @('update-index') + @($addFlag | Where-Object { $_ }) + @('--cacheinfo', "120000,$sha,$repoRelPath")
855 $cacheResult = & git @cacheArgs 2>&1
856 if ($LASTEXITCODE -ne 0) {
857 $errorMsg = @($cacheResult | ForEach-Object { $_.ToString() }) -join '; '
858 Write-Warning "Failed to update index entry for ${repoRelPath}: $errorMsg"
859 continue
860 }
861 $fixedCount++
862 Write-Verbose "Fixed index mode: $repoRelPath -> 120000"
863 }
864
865 return $fixedCount
866}
867
868Export-ModuleMember -Function @(
869 'Get-PluginItemName',
870 'Get-PluginSubdirectory',
871 'New-GenerateResult',
872 'New-MarketplaceManifestContent',
873 'New-PluginLink',
874 'New-PluginManifestContent',
875 'New-PluginReadmeContent',
876 'Repair-PluginSymlinkIndex',
877 'Test-SymlinkCapability',
878 'Write-MarketplaceManifest',
879 'Write-PluginDirectory'
880)