microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
feat/skill-validator-python-support

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/plugins/Modules/PluginHelpers.psm1

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