microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
feat/a11y-pr4-instructions

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/plugins/Modules/PluginHelpers.psm1

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