microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
feat/1637-l4-tests

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/plugins/Modules/PluginHelpers.psm1

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