microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
hve-core-v3.3.41

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/plugins/Modules/PluginHelpers.psm1

1013lines · 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 if (-not [string]::IsNullOrWhiteSpace($CollectionContent)) {
299 [void]$sb.AppendLine()
300 [void]$sb.AppendLine('## Overview')
301 [void]$sb.AppendLine()
302 [void]$sb.AppendLine($CollectionContent.TrimEnd())
303 }
304
305 [void]$sb.AppendLine()
306 [void]$sb.AppendLine('## Install')
307 [void]$sb.AppendLine()
308 [void]$sb.AppendLine('```bash')
309 [void]$sb.AppendLine("copilot plugin install $($Collection.id)@hve-core")
310 [void]$sb.AppendLine('```')
311
312 $sectionMap = [ordered]@{
313 agent = @{ Title = 'Agents'; Header = 'Agent' }
314 prompt = @{ Title = 'Commands'; Header = 'Command' }
315 instruction = @{ Title = 'Instructions'; Header = 'Instruction' }
316 skill = @{ Title = 'Skills'; Header = 'Skill' }
317 }
318
319 foreach ($entry in $sectionMap.GetEnumerator()) {
320 $kind = $entry.Key
321 $meta = $entry.Value
322 $kindItems = @($Items | Where-Object { $_.Kind -eq $kind })
323 if ($kindItems.Count -eq 0) {
324 continue
325 }
326
327 [void]$sb.AppendLine()
328 [void]$sb.AppendLine("## $($meta.Title)")
329 [void]$sb.AppendLine()
330
331 # Calculate column widths for aligned table output
332 $col1Width = $meta.Header.Length
333 $col2Width = 'Description'.Length
334 foreach ($item in $kindItems) {
335 if ($item.Name.Length -gt $col1Width) { $col1Width = $item.Name.Length }
336 if ($item.Description.Length -gt $col2Width) { $col2Width = $item.Description.Length }
337 }
338
339 [void]$sb.AppendLine("| $($meta.Header.PadRight($col1Width)) | $('Description'.PadRight($col2Width)) |")
340 [void]$sb.AppendLine('|' + ('-' * ($col1Width + 2)) + '|' + ('-' * ($col2Width + 2)) + '|')
341 foreach ($item in $kindItems) {
342 [void]$sb.AppendLine("| $($item.Name.PadRight($col1Width)) | $($item.Description.PadRight($col2Width)) |")
343 }
344 }
345
346 [void]$sb.AppendLine()
347 [void]$sb.AppendLine('---')
348 [void]$sb.AppendLine()
349 [void]$sb.AppendLine('> Source: [microsoft/hve-core](https://github.com/microsoft/hve-core)')
350 [void]$sb.AppendLine()
351
352 return $sb.ToString()
353}
354
355function New-MarketplaceManifestContent {
356 <#
357 .SYNOPSIS
358 Generates marketplace.json content as a hashtable.
359
360 .DESCRIPTION
361 Creates a hashtable representing the marketplace manifest with repository
362 metadata, owner information, and plugin entries. Matches the schema used
363 by github/awesome-copilot.
364
365 .PARAMETER RepoName
366 Repository name used as the marketplace name.
367
368 .PARAMETER Description
369 Short description of the repository.
370
371 .PARAMETER Version
372 Semantic version string from package.json.
373
374 .PARAMETER OwnerName
375 Organization or individual owning the repository.
376
377 .PARAMETER Plugins
378 Array of ordered hashtables with name, description, and version keys
379 from New-PluginManifestContent.
380
381 .OUTPUTS
382 [hashtable] Marketplace manifest with name, metadata, owner, and plugins keys.
383 #>
384 [CmdletBinding()]
385 [OutputType([hashtable])]
386 param(
387 [Parameter(Mandatory = $true)]
388 [string]$RepoName,
389
390 [Parameter(Mandatory = $true)]
391 [string]$Description,
392
393 [Parameter(Mandatory = $true)]
394 [string]$Version,
395
396 [Parameter(Mandatory = $true)]
397 [string]$OwnerName,
398
399 [Parameter(Mandatory = $true)]
400 [AllowEmptyCollection()]
401 [array]$Plugins
402 )
403
404 $pluginEntries = @()
405 foreach ($plugin in $Plugins) {
406 $pluginEntries += [ordered]@{
407 name = $plugin.name
408 source = $plugin.name
409 description = $plugin.description
410 version = $plugin.version
411 }
412 }
413
414 return [ordered]@{
415 name = $RepoName
416 metadata = [ordered]@{
417 description = $Description
418 version = $Version
419 pluginRoot = './plugins'
420 }
421 owner = [ordered]@{
422 name = $OwnerName
423 }
424 plugins = $pluginEntries
425 }
426}
427
428function Write-MarketplaceManifest {
429 <#
430 .SYNOPSIS
431 Writes the marketplace.json file to .github/plugin/.
432
433 .DESCRIPTION
434 Assembles plugin metadata from generated collections and writes the
435 marketplace manifest to .github/plugin/marketplace.json. Creates the
436 directory when it does not exist.
437
438 .PARAMETER RepoRoot
439 Absolute path to the repository root directory.
440
441 .PARAMETER Collections
442 Array of collection manifest hashtables with id and description.
443
444 .PARAMETER DryRun
445 When specified, logs the action without writing to disk.
446 #>
447 [CmdletBinding()]
448 param(
449 [Parameter(Mandatory = $true)]
450 [ValidateNotNullOrEmpty()]
451 [string]$RepoRoot,
452
453 [Parameter(Mandatory = $true)]
454 [AllowEmptyCollection()]
455 [array]$Collections,
456
457 [Parameter(Mandatory = $false)]
458 [switch]$DryRun
459 )
460
461 $packageJsonPath = Join-Path -Path $RepoRoot -ChildPath 'package.json'
462 $packageJson = Get-Content -Path $packageJsonPath -Raw | ConvertFrom-Json
463
464 $plugins = @()
465 foreach ($collection in ($Collections | Sort-Object { $_.id })) {
466 $plugins += New-PluginManifestContent `
467 -CollectionId $collection.id `
468 -Description $collection.description `
469 -Version $packageJson.version
470 }
471
472 $manifest = New-MarketplaceManifestContent `
473 -RepoName $packageJson.name `
474 -Description $packageJson.description `
475 -Version $packageJson.version `
476 -OwnerName $packageJson.author `
477 -Plugins $plugins
478
479 $outputDir = Join-Path -Path $RepoRoot -ChildPath '.github' -AdditionalChildPath 'plugin'
480 $outputPath = Join-Path -Path $outputDir -ChildPath 'marketplace.json'
481
482 if ($DryRun) {
483 Write-Host " [DRY RUN] Would write marketplace.json at $outputPath" -ForegroundColor Yellow
484 return
485 }
486
487 if (-not (Test-Path -Path $outputDir)) {
488 New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
489 }
490
491 $manifestJson = $manifest | ConvertTo-Json -Depth 10
492 Set-ContentIfChanged -Path $outputPath -Value $manifestJson | Out-Null
493 Write-Host " Marketplace manifest: $outputPath" -ForegroundColor Green
494}
495
496function New-GenerateResult {
497 <#
498 .SYNOPSIS
499 Creates a standardized result object.
500
501 .DESCRIPTION
502 Returns a hashtable representing the outcome of a plugin generation run
503 with success status, plugin count, and optional error message.
504
505 .PARAMETER Success
506 Whether the operation succeeded.
507
508 .PARAMETER PluginCount
509 Number of plugins generated.
510
511 .PARAMETER ErrorMessage
512 Optional error message when Success is $false.
513
514 .OUTPUTS
515 [hashtable] Result with Success, PluginCount, and ErrorMessage keys.
516 #>
517 [CmdletBinding()]
518 [OutputType([hashtable])]
519 param(
520 [Parameter(Mandatory = $true)]
521 [bool]$Success,
522
523 [Parameter(Mandatory = $true)]
524 [int]$PluginCount,
525
526 [Parameter(Mandatory = $false)]
527 [string]$ErrorMessage = ''
528 )
529
530 return @{
531 Success = $Success
532 PluginCount = $PluginCount
533 ErrorMessage = $ErrorMessage
534 }
535}
536
537# ---------------------------------------------------------------------------
538# I/O Functions (file system operations)
539# ---------------------------------------------------------------------------
540
541function Test-SymlinkCapability {
542 <#
543 .SYNOPSIS
544 Probes whether the current process can create symbolic links.
545
546 .DESCRIPTION
547 Creates a temporary file and attempts to symlink to it. Returns $true
548 when the OS and process privileges allow symlink creation, $false
549 otherwise. The probe directory is cleaned up unconditionally.
550 #>
551 [CmdletBinding()]
552 [OutputType([bool])]
553 param()
554
555 $tempDir = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "hve-symlink-probe-$PID"
556 $targetFile = Join-Path -Path $tempDir -ChildPath 'target.txt'
557 $linkFile = Join-Path -Path $tempDir -ChildPath 'link.txt'
558 try {
559 New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
560 Set-Content -Path $targetFile -Value 'probe' -NoNewline
561 New-Item -ItemType SymbolicLink -Path $linkFile -Target $targetFile -ErrorAction Stop | Out-Null
562 return $true
563 }
564 catch {
565 return $false
566 }
567 finally {
568 if (Test-Path -Path $tempDir) {
569 Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue
570 }
571 }
572}
573
574function New-PluginLink {
575 <#
576 .SYNOPSIS
577 Links a source path into a plugin destination via symlink or text stub.
578
579 .DESCRIPTION
580 When SymlinkCapable is set, creates a relative symbolic link from
581 DestinationPath to SourcePath. Otherwise writes a text stub file
582 containing the relative path, matching the format git produces when
583 core.symlinks is false. Text stubs keep git status clean on Windows
584 without Developer Mode or elevated privileges.
585
586 .PARAMETER SourcePath
587 Absolute path to the real file or directory.
588
589 .PARAMETER DestinationPath
590 Absolute path where the link or text stub will be created.
591
592 .PARAMETER SymlinkCapable
593 When set, create a symbolic link; otherwise write a text stub.
594 #>
595 [CmdletBinding()]
596 param(
597 [Parameter(Mandatory = $true)]
598 [string]$SourcePath,
599
600 [Parameter(Mandatory = $true)]
601 [string]$DestinationPath,
602
603 [Parameter(Mandatory = $false)]
604 [switch]$SymlinkCapable
605 )
606
607 $destinationDir = Split-Path -Parent $DestinationPath
608 if (-not (Test-Path -Path $destinationDir)) {
609 New-Item -ItemType Directory -Path $destinationDir -Force | Out-Null
610 }
611
612 $relativePath = [System.IO.Path]::GetRelativePath($destinationDir, $SourcePath) -replace '\\', '/'
613
614 if ($SymlinkCapable) {
615 New-Item -ItemType SymbolicLink -Path $DestinationPath -Value $relativePath -Force | Out-Null
616 }
617 else {
618 Set-ContentIfChanged -Path $DestinationPath -Value $relativePath | Out-Null
619 }
620}
621
622function Write-PluginDirectory {
623 <#
624 .SYNOPSIS
625 Creates a complete plugin directory structure from a collection.
626
627 .DESCRIPTION
628 Builds the full plugin layout under the specified plugins directory,
629 including subdirectories for agents, commands, instructions, and skills.
630 Each item is linked or copied from the plugin directory back to its
631 source in the repository. Generates plugin.json and README.md.
632
633 .PARAMETER Collection
634 Parsed collection manifest hashtable with id, name, description, and items.
635
636 .PARAMETER PluginsDir
637 Absolute path to the root plugins output directory.
638
639 .PARAMETER RepoRoot
640 Absolute path to the repository root.
641
642 .PARAMETER Version
643 Semantic version string from the repository package.json.
644
645 .PARAMETER Maturity
646 Optional collection-level maturity string. Forwarded to
647 New-PluginReadmeContent for maturity notice injection.
648
649 .PARAMETER DryRun
650 When specified, logs actions without creating files or directories.
651
652 .PARAMETER SymlinkCapable
653 When specified, creates symbolic links; otherwise copies files.
654
655 .OUTPUTS
656 [hashtable] Result with Success, AgentCount, CommandCount, InstructionCount,
657 and SkillCount keys.
658 #>
659 [CmdletBinding()]
660 [OutputType([hashtable])]
661 param(
662 [Parameter(Mandatory = $true)]
663 [hashtable]$Collection,
664
665 [Parameter(Mandatory = $true)]
666 [string]$PluginsDir,
667
668 [Parameter(Mandatory = $true)]
669 [string]$RepoRoot,
670
671 [Parameter(Mandatory = $true)]
672 [string]$Version,
673
674 [Parameter(Mandatory = $false)]
675 [AllowNull()]
676 [AllowEmptyString()]
677 [string]$Maturity,
678
679 [Parameter(Mandatory = $false)]
680 [switch]$DryRun,
681
682 [Parameter(Mandatory = $false)]
683 [switch]$SymlinkCapable
684 )
685
686 $collectionId = $Collection.id
687 $pluginRoot = Join-Path -Path $PluginsDir -ChildPath $collectionId
688
689 $counts = @{
690 AgentCount = 0
691 CommandCount = 0
692 InstructionCount = 0
693 SkillCount = 0
694 }
695
696 # Track unique directories per kind for plugin.json path arrays
697 $agentDirs = [System.Collections.Generic.HashSet[string]]::new(
698 [System.StringComparer]::OrdinalIgnoreCase
699 )
700 $commandDirs = [System.Collections.Generic.HashSet[string]]::new(
701 [System.StringComparer]::OrdinalIgnoreCase
702 )
703 $skillDirs = [System.Collections.Generic.HashSet[string]]::new(
704 [System.StringComparer]::OrdinalIgnoreCase
705 )
706
707 $readmeItems = @()
708 $generatedFiles = [System.Collections.Generic.HashSet[string]]::new(
709 [System.StringComparer]::OrdinalIgnoreCase
710 )
711
712 foreach ($item in $Collection.items) {
713 $kind = $item.kind
714 $sourcePath = Join-Path -Path $RepoRoot -ChildPath $item.path
715 $subdir = Get-PluginSubdirectory -Kind $kind
716
717 if ($kind -eq 'skill') {
718 # Skills are directory symlinks; use the directory name as FileName
719 $fileName = Split-Path -Leaf $item.path
720 $itemName = Get-PluginItemName -FileName $fileName -Kind $kind
721 $itemSubpath = Get-PluginItemSubpath -Path $item.path -Kind $kind
722 if ($itemSubpath) {
723 $destPath = Join-Path -Path $pluginRoot -ChildPath $subdir -AdditionalChildPath $itemSubpath, $itemName
724 } else {
725 $destPath = Join-Path -Path $pluginRoot -ChildPath $subdir -AdditionalChildPath $itemName
726 }
727
728 # Read frontmatter from SKILL.md for description; fall back to directory name
729 $skillMdPath = Join-Path -Path $sourcePath -ChildPath 'SKILL.md'
730 if (Test-Path -Path $skillMdPath) {
731 $frontmatter = Get-ArtifactFrontmatter -FilePath $skillMdPath -FallbackDescription $fileName
732 $description = $frontmatter.description
733 }
734 else {
735 $description = $fileName
736 }
737 }
738 else {
739 $fileName = Split-Path -Leaf $item.path
740 $itemName = Get-PluginItemName -FileName $fileName -Kind $kind
741 $itemSubpath = Get-PluginItemSubpath -Path $item.path -Kind $kind
742 if ($itemSubpath) {
743 $destPath = Join-Path -Path $pluginRoot -ChildPath $subdir -AdditionalChildPath $itemSubpath, $itemName
744 } else {
745 $destPath = Join-Path -Path $pluginRoot -ChildPath $subdir -AdditionalChildPath $itemName
746 }
747
748 # Read frontmatter from the source file for description
749 $fallback = $itemName -replace '\.md$', ''
750 if (Test-Path -Path $sourcePath) {
751 $frontmatter = Get-ArtifactFrontmatter -FilePath $sourcePath -FallbackDescription $fallback
752 $description = $frontmatter.description
753 }
754 else {
755 $description = $fallback
756 Write-Warning "Source file not found: $sourcePath"
757 }
758 }
759
760 $readmeItems += @{
761 Name = $itemName -replace '\.md$', ''
762 Description = $description
763 Kind = $kind
764 }
765
766 # Update counts and collect parent directories for manifest paths
767 switch ($kind) {
768 'agent' {
769 $counts.AgentCount++
770 $parentDir = Split-Path -Parent $destPath
771 $relDir = [System.IO.Path]::GetRelativePath($pluginRoot, $parentDir) -replace '\\', '/'
772 [void]$agentDirs.Add("$relDir/")
773 }
774 'prompt' {
775 $counts.CommandCount++
776 $parentDir = Split-Path -Parent $destPath
777 $relDir = [System.IO.Path]::GetRelativePath($pluginRoot, $parentDir) -replace '\\', '/'
778 [void]$commandDirs.Add("$relDir/")
779 }
780 'instruction' { $counts.InstructionCount++ }
781 'skill' {
782 $counts.SkillCount++
783 # Skills: the CLI scans for <name>/SKILL.md; point at the grandparent
784 $parentDir = Split-Path -Parent $destPath
785 $relDir = [System.IO.Path]::GetRelativePath($pluginRoot, $parentDir) -replace '\\', '/'
786 [void]$skillDirs.Add("$relDir/")
787 }
788 }
789
790 [void]$generatedFiles.Add($destPath)
791
792 if ($DryRun) {
793 Write-Verbose "DryRun: Would create link $destPath -> $sourcePath"
794 continue
795 }
796
797 New-PluginLink -SourcePath $sourcePath -DestinationPath $destPath -SymlinkCapable:$SymlinkCapable
798 }
799
800 # Link shared resource directories (unconditional, all plugins)
801 $sharedDirs = @(
802 @{ Source = 'docs/templates'; Destination = 'docs/templates' }
803 @{ Source = 'scripts/lib'; Destination = 'scripts/lib' }
804 )
805
806 foreach ($dir in $sharedDirs) {
807 $sourcePath = Join-Path -Path $RepoRoot -ChildPath $dir.Source
808 $destPath = Join-Path -Path $pluginRoot -ChildPath $dir.Destination
809
810 if (-not (Test-Path -Path $sourcePath)) {
811 Write-Warning "Shared directory not found: $sourcePath"
812 continue
813 }
814
815 [void]$generatedFiles.Add($destPath)
816
817 if ($DryRun) {
818 Write-Verbose "DryRun: Would create shared directory link $destPath -> $sourcePath"
819 continue
820 }
821
822 New-PluginLink -SourcePath $sourcePath -DestinationPath $destPath -SymlinkCapable:$SymlinkCapable
823 }
824
825 # Generate plugin.json with explicit path arrays for CLI discovery
826 $manifestDir = Join-Path -Path $pluginRoot -ChildPath '.github' -AdditionalChildPath 'plugin'
827 $manifestPath = Join-Path -Path $manifestDir -ChildPath 'plugin.json'
828 $manifest = New-PluginManifestContent `
829 -CollectionId $collectionId `
830 -Description $Collection.description `
831 -Version $Version `
832 -AgentPaths @($agentDirs) `
833 -CommandPaths @($commandDirs) `
834 -SkillPaths @($skillDirs)
835 [void]$generatedFiles.Add($manifestPath)
836
837 if ($DryRun) {
838 Write-Verbose "DryRun: Would write plugin.json at $manifestPath"
839 }
840 else {
841 if (-not (Test-Path -Path $manifestDir)) {
842 New-Item -ItemType Directory -Path $manifestDir -Force | Out-Null
843 }
844 $jsonContent = $manifest | ConvertTo-Json -Depth 10
845 Set-ContentIfChanged -Path $manifestPath -Value $jsonContent | Out-Null
846 }
847
848 # Generate README.md
849 $readmePath = Join-Path -Path $pluginRoot -ChildPath 'README.md'
850 $collectionMdPath = Join-Path -Path $RepoRoot -ChildPath "collections/$collectionId.collection.md"
851 $collectionContent = if (Test-Path -Path $collectionMdPath) {
852 Get-Content -Path $collectionMdPath -Raw
853 } else { $null }
854 $readmeContent = New-PluginReadmeContent -Collection $Collection -Items $readmeItems -Maturity $Maturity -CollectionContent $collectionContent
855 [void]$generatedFiles.Add($readmePath)
856
857 if ($DryRun) {
858 Write-Verbose "DryRun: Would write README.md at $readmePath"
859 }
860 else {
861 Set-ContentIfChanged -Path $readmePath -Value $readmeContent | Out-Null
862 }
863
864 return @{
865 Success = $true
866 AgentCount = $counts.AgentCount
867 CommandCount = $counts.CommandCount
868 InstructionCount = $counts.InstructionCount
869 SkillCount = $counts.SkillCount
870 GeneratedFiles = $generatedFiles
871 }
872}
873
874function Repair-PluginSymlinkIndex {
875 <#
876 .SYNOPSIS
877 Fixes git index modes for text stub files so they register as symlinks.
878
879 .DESCRIPTION
880 On systems where symlinks are unavailable (Windows without Developer Mode),
881 New-PluginLink writes text stubs containing relative paths. Git stages
882 these as mode 100644 (regular file). This function re-indexes each text
883 stub as mode 120000 (symlink) so that Linux/macOS checkouts materialize
884 real symbolic links.
885
886 .PARAMETER PluginsDir
887 Absolute path to the plugins output directory.
888
889 .PARAMETER RepoRoot
890 Absolute path to the repository root (git working tree).
891
892 .PARAMETER DryRun
893 When specified, logs what would be fixed without modifying the index.
894
895 .OUTPUTS
896 [int] Number of index entries corrected.
897 #>
898 [CmdletBinding()]
899 [OutputType([int])]
900 param(
901 [Parameter(Mandatory = $true)]
902 [ValidateNotNullOrEmpty()]
903 [string]$PluginsDir,
904
905 [Parameter(Mandatory = $true)]
906 [ValidateNotNullOrEmpty()]
907 [string]$RepoRoot,
908
909 [Parameter(Mandatory = $false)]
910 [switch]$DryRun
911 )
912
913 if (-not (Test-Path -Path $PluginsDir)) {
914 return 0
915 }
916
917 # Build a set of paths already tracked in the git index under plugins/.
918 # --index-info silently ignores untracked paths (PowerShell pipe encoding
919 # issue), so new files must be added individually via --cacheinfo.
920 $trackedPaths = [System.Collections.Generic.HashSet[string]]::new(
921 [System.StringComparer]::OrdinalIgnoreCase
922 )
923 $alreadySymlink = [System.Collections.Generic.HashSet[string]]::new(
924 [System.StringComparer]::OrdinalIgnoreCase
925 )
926 $pluginsRel = [System.IO.Path]::GetRelativePath($RepoRoot, $PluginsDir) -replace '\\', '/'
927 $lsOutput = git ls-files --stage -- $pluginsRel 2>$null
928 if ($lsOutput) {
929 foreach ($line in @($lsOutput)) {
930 if ($line -match '^(\d+)\s+[0-9a-f]+\s+\d+\t(.+)$') {
931 [void]$trackedPaths.Add($Matches[2])
932 if ($Matches[1] -eq '120000') {
933 [void]$alreadySymlink.Add($Matches[2])
934 }
935 }
936 }
937 }
938
939 $fixedCount = 0
940 $files = Get-ChildItem -Path $PluginsDir -File -Recurse
941
942 foreach ($file in $files) {
943 # Text stubs are small files whose content is a relative path with
944 # forward slashes, no line breaks, starting with ../
945 if ($file.Length -gt 500) {
946 continue
947 }
948
949 $content = [System.IO.File]::ReadAllText($file.FullName)
950
951 if ($content -notmatch '^\.\./') {
952 continue
953 }
954 if ($content.Contains("`n") -or $content.Contains("`r")) {
955 continue
956 }
957
958 $repoRelPath = [System.IO.Path]::GetRelativePath($RepoRoot, $file.FullName) -replace '\\', '/'
959
960 if ($alreadySymlink.Contains($repoRelPath)) {
961 continue
962 }
963
964 if ($DryRun) {
965 Write-Verbose "DryRun: Would fix index mode for $repoRelPath"
966 $fixedCount++
967 continue
968 }
969
970 $hashOutput = git hash-object -w -- $file.FullName 2>&1
971 if ($LASTEXITCODE -ne 0) {
972 Write-Warning "Failed to hash-object for $repoRelPath"
973 continue
974 }
975
976 # Extract clean SHA string, filtering out any ErrorRecord objects
977 $sha = @($hashOutput | Where-Object { $_ -is [string] -and $_ -match '^[0-9a-f]{40}' })[0]
978 if (-not $sha) {
979 Write-Warning "No valid SHA returned for $repoRelPath"
980 continue
981 }
982
983 # Use --add for untracked files; harmless for already-tracked entries.
984 # Avoids --index-info piping which breaks on Windows due to CRLF stdin.
985 $addFlag = if (-not $trackedPaths.Contains($repoRelPath)) { '--add' } else { $null }
986 $cacheArgs = @('update-index') + @($addFlag | Where-Object { $_ }) + @('--cacheinfo', "120000,$sha,$repoRelPath")
987 $cacheResult = & git @cacheArgs 2>&1
988 if ($LASTEXITCODE -ne 0) {
989 $errorMsg = @($cacheResult | ForEach-Object { $_.ToString() }) -join '; '
990 Write-Warning "Failed to update index entry for ${repoRelPath}: $errorMsg"
991 continue
992 }
993 $fixedCount++
994 Write-Verbose "Fixed index mode: $repoRelPath -> 120000"
995 }
996
997 return $fixedCount
998}
999
1000Export-ModuleMember -Function @(
1001 'Get-PluginItemName',
1002 'Get-PluginItemSubpath',
1003 'Get-PluginSubdirectory',
1004 'New-GenerateResult',
1005 'New-MarketplaceManifestContent',
1006 'New-PluginLink',
1007 'New-PluginManifestContent',
1008 'New-PluginReadmeContent',
1009 'Repair-PluginSymlinkIndex',
1010 'Test-SymlinkCapability',
1011 'Write-MarketplaceManifest',
1012 'Write-PluginDirectory'
1013)