microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
1d56d25494d03b3ff5b9bf68c8ec3e7e38d351d5

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/plugins/Modules/PluginHelpers.psm1

871lines · 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 $manifest | ConvertTo-Json -Depth 10 | Set-Content -Path $outputPath -Encoding utf8 -NoNewline
403 Write-Host " Marketplace manifest: $outputPath" -ForegroundColor Green
404}
405
406function New-GenerateResult {
407 <#
408 .SYNOPSIS
409 Creates a standardized result object.
410
411 .DESCRIPTION
412 Returns a hashtable representing the outcome of a plugin generation run
413 with success status, plugin count, and optional error message.
414
415 .PARAMETER Success
416 Whether the operation succeeded.
417
418 .PARAMETER PluginCount
419 Number of plugins generated.
420
421 .PARAMETER ErrorMessage
422 Optional error message when Success is $false.
423
424 .OUTPUTS
425 [hashtable] Result with Success, PluginCount, and ErrorMessage keys.
426 #>
427 [CmdletBinding()]
428 [OutputType([hashtable])]
429 param(
430 [Parameter(Mandatory = $true)]
431 [bool]$Success,
432
433 [Parameter(Mandatory = $true)]
434 [int]$PluginCount,
435
436 [Parameter(Mandatory = $false)]
437 [string]$ErrorMessage = ''
438 )
439
440 return @{
441 Success = $Success
442 PluginCount = $PluginCount
443 ErrorMessage = $ErrorMessage
444 }
445}
446
447# ---------------------------------------------------------------------------
448# I/O Functions (file system operations)
449# ---------------------------------------------------------------------------
450
451function Test-SymlinkCapability {
452 <#
453 .SYNOPSIS
454 Probes whether the current process can create symbolic links.
455
456 .DESCRIPTION
457 Creates a temporary file and attempts to symlink to it. Returns $true
458 when the OS and process privileges allow symlink creation, $false
459 otherwise. The probe directory is cleaned up unconditionally.
460 #>
461 [CmdletBinding()]
462 [OutputType([bool])]
463 param()
464
465 $tempDir = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "hve-symlink-probe-$PID"
466 $targetFile = Join-Path -Path $tempDir -ChildPath 'target.txt'
467 $linkFile = Join-Path -Path $tempDir -ChildPath 'link.txt'
468 try {
469 New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
470 Set-Content -Path $targetFile -Value 'probe' -NoNewline
471 New-Item -ItemType SymbolicLink -Path $linkFile -Target $targetFile -ErrorAction Stop | Out-Null
472 return $true
473 }
474 catch {
475 return $false
476 }
477 finally {
478 if (Test-Path -Path $tempDir) {
479 Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
480 }
481 }
482}
483
484function New-PluginLink {
485 <#
486 .SYNOPSIS
487 Links a source path into a plugin destination via symlink or text stub.
488
489 .DESCRIPTION
490 When SymlinkCapable is set, creates a relative symbolic link from
491 DestinationPath to SourcePath. Otherwise writes a text stub file
492 containing the relative path, matching the format git produces when
493 core.symlinks is false. Text stubs keep git status clean on Windows
494 without Developer Mode or elevated privileges.
495
496 .PARAMETER SourcePath
497 Absolute path to the real file or directory.
498
499 .PARAMETER DestinationPath
500 Absolute path where the link or text stub will be created.
501
502 .PARAMETER SymlinkCapable
503 When set, create a symbolic link; otherwise write a text stub.
504 #>
505 [CmdletBinding()]
506 param(
507 [Parameter(Mandatory = $true)]
508 [string]$SourcePath,
509
510 [Parameter(Mandatory = $true)]
511 [string]$DestinationPath,
512
513 [Parameter(Mandatory = $false)]
514 [switch]$SymlinkCapable
515 )
516
517 $destinationDir = Split-Path -Parent $DestinationPath
518 if (-not (Test-Path -Path $destinationDir)) {
519 New-Item -ItemType Directory -Path $destinationDir -Force | Out-Null
520 }
521
522 $relativePath = [System.IO.Path]::GetRelativePath($destinationDir, $SourcePath) -replace '\\', '/'
523
524 if ($SymlinkCapable) {
525 New-Item -ItemType SymbolicLink -Path $DestinationPath -Value $relativePath -Force | Out-Null
526 }
527 else {
528 [System.IO.File]::WriteAllText($DestinationPath, $relativePath)
529 }
530}
531
532function Write-PluginDirectory {
533 <#
534 .SYNOPSIS
535 Creates a complete plugin directory structure from a collection.
536
537 .DESCRIPTION
538 Builds the full plugin layout under the specified plugins directory,
539 including subdirectories for agents, commands, instructions, and skills.
540 Each item is linked or copied from the plugin directory back to its
541 source in the repository. Generates plugin.json and README.md.
542
543 .PARAMETER Collection
544 Parsed collection manifest hashtable with id, name, description, and items.
545
546 .PARAMETER PluginsDir
547 Absolute path to the root plugins output directory.
548
549 .PARAMETER RepoRoot
550 Absolute path to the repository root.
551
552 .PARAMETER Version
553 Semantic version string from the repository package.json.
554
555 .PARAMETER Maturity
556 Optional collection-level maturity string. Forwarded to
557 New-PluginReadmeContent for maturity notice injection.
558
559 .PARAMETER DryRun
560 When specified, logs actions without creating files or directories.
561
562 .PARAMETER SymlinkCapable
563 When specified, creates symbolic links; otherwise copies files.
564
565 .OUTPUTS
566 [hashtable] Result with Success, AgentCount, CommandCount, InstructionCount,
567 and SkillCount keys.
568 #>
569 [CmdletBinding()]
570 [OutputType([hashtable])]
571 param(
572 [Parameter(Mandatory = $true)]
573 [hashtable]$Collection,
574
575 [Parameter(Mandatory = $true)]
576 [string]$PluginsDir,
577
578 [Parameter(Mandatory = $true)]
579 [string]$RepoRoot,
580
581 [Parameter(Mandatory = $true)]
582 [string]$Version,
583
584 [Parameter(Mandatory = $false)]
585 [AllowNull()]
586 [AllowEmptyString()]
587 [string]$Maturity,
588
589 [Parameter(Mandatory = $false)]
590 [switch]$DryRun,
591
592 [Parameter(Mandatory = $false)]
593 [switch]$SymlinkCapable
594 )
595
596 $collectionId = $Collection.id
597 $pluginRoot = Join-Path -Path $PluginsDir -ChildPath $collectionId
598
599 $counts = @{
600 AgentCount = 0
601 CommandCount = 0
602 InstructionCount = 0
603 SkillCount = 0
604 }
605
606 $readmeItems = @()
607
608 foreach ($item in $Collection.items) {
609 $kind = $item.kind
610 $sourcePath = Join-Path -Path $RepoRoot -ChildPath $item.path
611 $subdir = Get-PluginSubdirectory -Kind $kind
612
613 if ($kind -eq 'skill') {
614 # Skills are directory symlinks; use the directory name as FileName
615 $fileName = Split-Path -Leaf $item.path
616 $itemName = Get-PluginItemName -FileName $fileName -Kind $kind
617 $destPath = Join-Path -Path $pluginRoot -ChildPath $subdir -AdditionalChildPath $itemName
618
619 # Read frontmatter from SKILL.md for description; fall back to directory name
620 $skillMdPath = Join-Path -Path $sourcePath -ChildPath 'SKILL.md'
621 if (Test-Path -Path $skillMdPath) {
622 $frontmatter = Get-ArtifactFrontmatter -FilePath $skillMdPath -FallbackDescription $fileName
623 $description = $frontmatter.description
624 }
625 else {
626 $description = $fileName
627 }
628 }
629 else {
630 $fileName = Split-Path -Leaf $item.path
631 $itemName = Get-PluginItemName -FileName $fileName -Kind $kind
632 $destPath = Join-Path -Path $pluginRoot -ChildPath $subdir -AdditionalChildPath $itemName
633
634 # Read frontmatter from the source file for description
635 $fallback = $itemName -replace '\.md$', ''
636 if (Test-Path -Path $sourcePath) {
637 $frontmatter = Get-ArtifactFrontmatter -FilePath $sourcePath -FallbackDescription $fallback
638 $description = $frontmatter.description
639 }
640 else {
641 $description = $fallback
642 Write-Warning "Source file not found: $sourcePath"
643 }
644 }
645
646 $readmeItems += @{
647 Name = $itemName -replace '\.md$', ''
648 Description = $description
649 Kind = $kind
650 }
651
652 # Update counts
653 switch ($kind) {
654 'agent' { $counts.AgentCount++ }
655 'prompt' { $counts.CommandCount++ }
656 'instruction' { $counts.InstructionCount++ }
657 'skill' { $counts.SkillCount++ }
658 }
659
660 if ($DryRun) {
661 Write-Verbose "DryRun: Would create link $destPath -> $sourcePath"
662 continue
663 }
664
665 New-PluginLink -SourcePath $sourcePath -DestinationPath $destPath -SymlinkCapable:$SymlinkCapable
666 }
667
668 # Link shared resource directories (unconditional, all plugins)
669 $sharedDirs = @(
670 @{ Source = 'docs/templates'; Destination = 'docs/templates' }
671 @{ Source = 'scripts/lib'; Destination = 'scripts/lib' }
672 )
673
674 foreach ($dir in $sharedDirs) {
675 $sourcePath = Join-Path -Path $RepoRoot -ChildPath $dir.Source
676 $destPath = Join-Path -Path $pluginRoot -ChildPath $dir.Destination
677
678 if (-not (Test-Path -Path $sourcePath)) {
679 Write-Warning "Shared directory not found: $sourcePath"
680 continue
681 }
682
683 if ($DryRun) {
684 Write-Verbose "DryRun: Would create shared directory link $destPath -> $sourcePath"
685 continue
686 }
687
688 New-PluginLink -SourcePath $sourcePath -DestinationPath $destPath -SymlinkCapable:$SymlinkCapable
689 }
690
691 # Generate plugin.json
692 $manifestDir = Join-Path -Path $pluginRoot -ChildPath '.github' -AdditionalChildPath 'plugin'
693 $manifestPath = Join-Path -Path $manifestDir -ChildPath 'plugin.json'
694 $manifest = New-PluginManifestContent -CollectionId $collectionId -Description $Collection.description -Version $Version
695
696 if ($DryRun) {
697 Write-Verbose "DryRun: Would write plugin.json at $manifestPath"
698 }
699 else {
700 if (-not (Test-Path -Path $manifestDir)) {
701 New-Item -ItemType Directory -Path $manifestDir -Force | Out-Null
702 }
703 $manifest | ConvertTo-Json -Depth 10 | Set-Content -Path $manifestPath -Encoding utf8 -NoNewline
704 }
705
706 # Generate README.md
707 $readmePath = Join-Path -Path $pluginRoot -ChildPath 'README.md'
708 $collectionMdPath = Join-Path -Path $RepoRoot -ChildPath "collections/$collectionId.collection.md"
709 $collectionContent = if (Test-Path -Path $collectionMdPath) {
710 Get-Content -Path $collectionMdPath -Raw
711 } else { $null }
712 $readmeContent = New-PluginReadmeContent -Collection $Collection -Items $readmeItems -Maturity $Maturity -CollectionContent $collectionContent
713
714 if ($DryRun) {
715 Write-Verbose "DryRun: Would write README.md at $readmePath"
716 }
717 else {
718 Set-Content -Path $readmePath -Value $readmeContent -Encoding utf8 -NoNewline
719 }
720
721 return @{
722 Success = $true
723 AgentCount = $counts.AgentCount
724 CommandCount = $counts.CommandCount
725 InstructionCount = $counts.InstructionCount
726 SkillCount = $counts.SkillCount
727 }
728}
729
730function Repair-PluginSymlinkIndex {
731 <#
732 .SYNOPSIS
733 Fixes git index modes for text stub files so they register as symlinks.
734
735 .DESCRIPTION
736 On systems where symlinks are unavailable (Windows without Developer Mode),
737 New-PluginLink writes text stubs containing relative paths. Git stages
738 these as mode 100644 (regular file). This function re-indexes each text
739 stub as mode 120000 (symlink) so that Linux/macOS checkouts materialize
740 real symbolic links.
741
742 .PARAMETER PluginsDir
743 Absolute path to the plugins output directory.
744
745 .PARAMETER RepoRoot
746 Absolute path to the repository root (git working tree).
747
748 .PARAMETER DryRun
749 When specified, logs what would be fixed without modifying the index.
750
751 .OUTPUTS
752 [int] Number of index entries corrected.
753 #>
754 [CmdletBinding()]
755 [OutputType([int])]
756 param(
757 [Parameter(Mandatory = $true)]
758 [ValidateNotNullOrEmpty()]
759 [string]$PluginsDir,
760
761 [Parameter(Mandatory = $true)]
762 [ValidateNotNullOrEmpty()]
763 [string]$RepoRoot,
764
765 [Parameter(Mandatory = $false)]
766 [switch]$DryRun
767 )
768
769 if (-not (Test-Path -Path $PluginsDir)) {
770 return 0
771 }
772
773 # Build a set of paths already tracked in the git index under plugins/.
774 # --index-info silently ignores untracked paths (PowerShell pipe encoding
775 # issue), so new files must be added individually via --cacheinfo.
776 $trackedPaths = [System.Collections.Generic.HashSet[string]]::new(
777 [System.StringComparer]::OrdinalIgnoreCase
778 )
779 $pluginsRel = [System.IO.Path]::GetRelativePath($RepoRoot, $PluginsDir) -replace '\\', '/'
780 $lsOutput = git ls-files -- $pluginsRel 2>$null
781 if ($lsOutput) {
782 foreach ($p in @($lsOutput)) { [void]$trackedPaths.Add($p) }
783 }
784
785 $fixedCount = 0
786 $newEntries = [System.Collections.Generic.List[PSCustomObject]]::new()
787 $batchEntries = [System.Collections.Generic.List[string]]::new()
788 $files = Get-ChildItem -Path $PluginsDir -File -Recurse
789
790 foreach ($file in $files) {
791 # Text stubs are small files whose content is a relative path with
792 # forward slashes, no line breaks, starting with ../
793 if ($file.Length -gt 500) {
794 continue
795 }
796
797 $content = [System.IO.File]::ReadAllText($file.FullName)
798
799 if ($content -notmatch '^\.\./') {
800 continue
801 }
802 if ($content.Contains("`n") -or $content.Contains("`r")) {
803 continue
804 }
805
806 $repoRelPath = [System.IO.Path]::GetRelativePath($RepoRoot, $file.FullName) -replace '\\', '/'
807
808 if ($DryRun) {
809 Write-Verbose "DryRun: Would fix index mode for $repoRelPath"
810 $fixedCount++
811 continue
812 }
813
814 $hashOutput = git hash-object -w -- $file.FullName 2>&1
815 if ($LASTEXITCODE -ne 0) {
816 Write-Warning "Failed to hash-object for $repoRelPath"
817 continue
818 }
819
820 # Extract clean SHA string, filtering out any ErrorRecord objects
821 $sha = @($hashOutput | Where-Object { $_ -is [string] -and $_ -match '^[0-9a-f]{40}' })[0]
822 if (-not $sha) {
823 Write-Warning "No valid SHA returned for $repoRelPath"
824 continue
825 }
826
827 if ($trackedPaths.Contains($repoRelPath)) {
828 $batchEntries.Add("120000 $sha`t$repoRelPath")
829 } else {
830 $newEntries.Add([PSCustomObject]@{ Sha = $sha; Path = $repoRelPath })
831 }
832 $fixedCount++
833 Write-Verbose "Queued index fix: $repoRelPath -> 120000"
834 }
835
836 # Add new/untracked files individually (typically few per run)
837 foreach ($entry in $newEntries) {
838 $cacheResult = git update-index --add --cacheinfo "120000,$($entry.Sha),$($entry.Path)" 2>&1
839 if ($LASTEXITCODE -ne 0) {
840 $errorMsg = @($cacheResult | ForEach-Object { $_.ToString() }) -join '; '
841 Write-Warning "Failed to add index entry for $($entry.Path): $errorMsg"
842 $fixedCount--
843 }
844 }
845
846 # Batch update existing entries in a single call to avoid index.lock contention
847 if ($batchEntries.Count -gt 0) {
848 $indexResult = $batchEntries | git update-index --index-info 2>&1
849 if ($LASTEXITCODE -ne 0) {
850 $errorMsg = @($indexResult | ForEach-Object { $_.ToString() }) -join '; '
851 Write-Warning "Failed to update git index: $errorMsg"
852 return 0
853 }
854 }
855
856 return $fixedCount
857}
858
859Export-ModuleMember -Function @(
860 'Get-PluginItemName',
861 'Get-PluginSubdirectory',
862 'New-GenerateResult',
863 'New-MarketplaceManifestContent',
864 'New-PluginLink',
865 'New-PluginManifestContent',
866 'New-PluginReadmeContent',
867 'Repair-PluginSymlinkIndex',
868 'Test-SymlinkCapability',
869 'Write-MarketplaceManifest',
870 'Write-PluginDirectory'
871)