microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
feat-ds-agent

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/plugins/Modules/PluginHelpers.psm1

987lines · modepreview

# Copyright (c) Microsoft Corporation.
# SPDX-License-Identifier: MIT

# PluginHelpers.psm1
#
# Purpose: Shared functions for the Copilot CLI plugin generation pipeline.
# Author: HVE Core Team

#Requires -Version 7.0

# ---------------------------------------------------------------------------
# Pure Functions (no file system side effects)
# ---------------------------------------------------------------------------

function Get-CollectionManifest {
    <#
    .SYNOPSIS
    Reads and parses a .collection.yml file.

    .DESCRIPTION
    Loads a collection manifest YAML file and returns its parsed content
    as a hashtable using ConvertFrom-Yaml.

    .PARAMETER CollectionPath
    Absolute or relative path to the .collection.yml file.

    .OUTPUTS
    [hashtable] Parsed collection data with id, name, description, items, etc.
    #>
    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory = $true)]
        [string]$CollectionPath
    )

    $content = Get-Content -Path $CollectionPath -Raw
    $manifest = ConvertFrom-Yaml -Yaml $content

    return $manifest
}

function Get-ArtifactFrontmatter {
    <#
    .SYNOPSIS
    Extracts YAML frontmatter from a markdown file.

    .DESCRIPTION
    Parses the YAML frontmatter block delimited by --- markers at the start
    of a markdown file. Returns a hashtable with description.

    .PARAMETER FilePath
    Path to the markdown file to parse.

    .PARAMETER FallbackDescription
    Default description if none found in frontmatter.

    .OUTPUTS
    [hashtable] With description key.
    #>
    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory = $true)]
        [string]$FilePath,

        [Parameter(Mandatory = $false)]
        [string]$FallbackDescription = ''
    )

    $content = Get-Content -Path $FilePath -Raw
    $description = ''

    if ($content -match '(?s)^---\s*\r?\n(.*?)\r?\n---') {
        $yamlContent = $Matches[1] -replace '\r\n', "`n" -replace '\r', "`n"
        try {
            $data = ConvertFrom-Yaml -Yaml $yamlContent
            if ($data.ContainsKey('description')) {
                $description = $data.description
            }
        }
        catch {
            Write-Warning "Failed to parse YAML frontmatter in $(Split-Path -Leaf $FilePath): $_"
        }
    }

    return @{
        description = if ($description) { $description } else { $FallbackDescription }
    }
}

function Resolve-CollectionItemMaturity {
    <#
    .SYNOPSIS
    Resolves effective maturity from collection item metadata.

    .DESCRIPTION
    Returns stable when maturity is omitted; otherwise returns the provided
    maturity string.

    .PARAMETER Maturity
    Optional maturity value from a collection item.

    .OUTPUTS
    [string] Effective maturity value.
    #>
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter()]
        [AllowNull()]
        [AllowEmptyString()]
        [string]$Maturity
    )

    if ([string]::IsNullOrWhiteSpace($Maturity)) {
        return 'stable'
    }

    return $Maturity
}

function Get-AllCollections {
    <#
    .SYNOPSIS
    Discovers and parses all .collection.yml files in a directory.

    .DESCRIPTION
    Scans the specified directory for files matching *.collection.yml and
    parses each one into a hashtable via Get-CollectionManifest.

    .PARAMETER CollectionsDir
    Path to the directory containing .collection.yml files.

    .OUTPUTS
    [hashtable[]] Array of parsed collection manifests.
    #>
    [CmdletBinding()]
    [OutputType([hashtable[]])]
    param(
        [Parameter(Mandatory = $true)]
        [string]$CollectionsDir
    )

    $files = Get-ChildItem -Path $CollectionsDir -Filter '*.collection.yml' -File
    $collections = @()

    foreach ($file in $files) {
        $manifest = Get-CollectionManifest -CollectionPath $file.FullName
        $collections += $manifest
    }

    return $collections
}

function Get-ArtifactFiles {
    <#
    .SYNOPSIS
    Discovers all artifact files from .github/ directories.

    .DESCRIPTION
    Scans .github/agents/, .github/prompts/, .github/instructions/ (recursively),
    and .github/skills/ to build a complete list of collection items. Returns
    repo-relative paths with forward slashes.

    .PARAMETER RepoRoot
    Absolute path to the repository root directory.

    .OUTPUTS
    [hashtable[]] Array of hashtables with path and kind keys.
    #>
    [CmdletBinding()]
    [OutputType([hashtable[]])]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$RepoRoot
    )

    $items = @()

    # Prompt-engineering artifacts discovered by .<kind>.md suffix under .github/
    # Keep explicit suffix mapping only where naming differs from manifest kind values.
    $gitHubDir = Join-Path -Path $RepoRoot -ChildPath '.github'
    if (Test-Path -Path $gitHubDir) {
        $suffixToKind = @{
            instructions = 'instruction'
        }

        $artifactFiles = Get-ChildItem -Path $gitHubDir -Filter '*.*.md' -File -Recurse
        foreach ($file in $artifactFiles) {
            if ($file.Name -notmatch '\.(?<suffix>[^.]+)\.md$') {
                continue
            }

            $suffix = $Matches['suffix'].ToLowerInvariant()
            $kind = if ($suffixToKind.ContainsKey($suffix)) { $suffixToKind[$suffix] } else { $suffix }
            $relativePath = [System.IO.Path]::GetRelativePath($RepoRoot, $file.FullName) -replace '\\', '/'

            # Exclude repo-specific artifacts under .github/**/hve-core/
            if ($relativePath -match '^\.github/.*/hve-core/') {
                continue
            }

            $items += @{ path = $relativePath; kind = $kind }
        }
    }

    # Skills (directories containing SKILL.md)
    $skillsDir = Join-Path -Path $RepoRoot -ChildPath '.github/skills'
    if (Test-Path -Path $skillsDir) {
        $skillDirs = Get-ChildItem -Path $skillsDir -Directory
        foreach ($dir in $skillDirs) {
            $skillFile = Join-Path -Path $dir.FullName -ChildPath 'SKILL.md'
            if (Test-Path -Path $skillFile) {
                $relativePath = [System.IO.Path]::GetRelativePath($RepoRoot, $dir.FullName) -replace '\\', '/'
                $items += @{ path = $relativePath; kind = 'skill' }
            }
        }
    }

    return $items
}

function Test-ArtifactDeprecated {
    <#
    .SYNOPSIS
    Checks whether an artifact has maturity deprecated in collection metadata.

    .DESCRIPTION
    Reads maturity from the provided collection item metadata value and
    returns $true when the effective value equals deprecated.

    .PARAMETER Maturity
    Optional maturity value from collection item metadata.

    .OUTPUTS
    [bool] True when the artifact is deprecated.
    #>
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter()]
        [AllowNull()]
        [AllowEmptyString()]
        [string]$Maturity
    )

    return ((Resolve-CollectionItemMaturity -Maturity $Maturity) -eq 'deprecated')
}

function Update-HveCoreAllCollection {
    <#
    .SYNOPSIS
    Auto-updates hve-core-all.collection.yml with all non-deprecated artifacts.

    .DESCRIPTION
    Discovers all artifacts from .github/ directories, excludes deprecated items,
    and rewrites the hve-core-all collection manifest. Preserves existing
    metadata fields (id, name, description, tags, display).

    .PARAMETER RepoRoot
    Absolute path to the repository root directory.

    .PARAMETER DryRun
    When specified, logs changes without writing to disk.

    .OUTPUTS
    [hashtable] With ItemCount, AddedCount, RemovedCount, and DeprecatedCount keys.
    #>
    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$RepoRoot,

        [Parameter(Mandatory = $false)]
        [switch]$DryRun
    )

    $collectionPath = Join-Path -Path $RepoRoot -ChildPath 'collections/hve-core-all.collection.yml'

    # Read existing manifest to preserve metadata
    $existing = Get-CollectionManifest -CollectionPath $collectionPath
    $existingPaths = @($existing.items | ForEach-Object { $_.path })

    # Discover all artifacts
    $allItems = Get-ArtifactFiles -RepoRoot $RepoRoot

    # Filter deprecated based on existing collection item maturity metadata
    $existingItemMaturities = @{}
    foreach ($existingItem in $existing.items) {
        $existingKey = "$($existingItem.kind)|$($existingItem.path)"
        $existingItemMaturities[$existingKey] = Resolve-CollectionItemMaturity -Maturity $existingItem.maturity
    }

    $deprecatedCount = 0
    $filteredItems = @()
    foreach ($item in $allItems) {
        $itemKey = "$($item.kind)|$($item.path)"
        $itemMaturity = 'stable'
        if ($existingItemMaturities.ContainsKey($itemKey)) {
            $itemMaturity = $existingItemMaturities[$itemKey]
        }

        if (Test-ArtifactDeprecated -Maturity $itemMaturity) {
            $deprecatedCount++
            Write-Verbose "Excluding deprecated: $($item.path)"
            continue
        }

        $filteredItems += @{
            path     = $item.path
            kind     = $item.kind
            maturity = $itemMaturity
        }
    }

    # Sort: known kinds first, then any additional kinds, then by path
    $kindOrder = @{ 'agent' = 0; 'prompt' = 1; 'instruction' = 2; 'skill' = 3 }
    $sortedItems = $filteredItems | Sort-Object `
        { if ($kindOrder.ContainsKey($_.kind)) { $kindOrder[$_.kind] } else { 100 } }, `
        { $_.kind }, `
        { $_.path }

    # Build new items array as ordered hashtables for clean YAML output
    $newItems = @()
    foreach ($item in $sortedItems) {
        $newItem = [ordered]@{
            path = $item.path
            kind = $item.kind
        }

        if ((Resolve-CollectionItemMaturity -Maturity $item.maturity) -ne 'stable') {
            $newItem['maturity'] = $item.maturity
        }

        $newItems += $newItem
    }

    # Compute diff
    $newPaths = @($sortedItems | ForEach-Object { $_.path })
    $added = @($newPaths | Where-Object { $_ -notin $existingPaths })
    $removed = @($existingPaths | Where-Object { $_ -notin $newPaths })

    Write-Host "`n--- hve-core-all Auto-Update ---" -ForegroundColor Cyan
    Write-Host "  Discovered: $($allItems.Count) artifacts"
    Write-Host "  Deprecated: $deprecatedCount (excluded)"
    Write-Host "  Final: $($newItems.Count) items"
    if ($added.Count -gt 0) {
        Write-Host "  Added: $($added -join ', ')" -ForegroundColor Green
    }
    if ($removed.Count -gt 0) {
        Write-Host "  Removed: $($removed -join ', ')" -ForegroundColor Yellow
    }

    if ($DryRun) {
        Write-Host '  [DRY RUN] No changes written' -ForegroundColor Yellow
    }
    else {
        # Rebuild manifest preserving metadata
        $manifest = [ordered]@{
            id          = $existing.id
            name        = $existing.name
            description = $existing.description
            tags        = $existing.tags
            items       = $newItems
            display     = $existing.display
        }

        $yaml = ConvertTo-Yaml -Data $manifest
        Set-Content -Path $collectionPath -Value $yaml -Encoding utf8 -NoNewline
        Write-Verbose "Updated $collectionPath"
    }

    return @{
        ItemCount       = $newItems.Count
        AddedCount      = $added.Count
        RemovedCount    = $removed.Count
        DeprecatedCount = $deprecatedCount
    }
}

function Get-PluginItemName {
    <#
    .SYNOPSIS
    Strips artifact-type suffix from a filename.

    .DESCRIPTION
    Removes the kind-specific suffix from a filename and returns the
    simplified name with a .md extension (or the directory name for skills).

    .PARAMETER FileName
    The original filename (e.g. task-researcher.agent.md).

    .PARAMETER Kind
    The artifact kind: agent, prompt, instruction, or skill.

    .OUTPUTS
    [string] The simplified item name.
    #>
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory = $true)]
        [string]$FileName,

        [Parameter(Mandatory = $true)]
        [ValidateSet('agent', 'prompt', 'instruction', 'skill')]
        [string]$Kind
    )

    switch ($Kind) {
        'agent' {
            return ($FileName -replace '\.agent\.md$', '') + '.md'
        }
        'prompt' {
            return ($FileName -replace '\.prompt\.md$', '') + '.md'
        }
        'instruction' {
            return ($FileName -replace '\.instructions\.md$', '') + '.md'
        }
        'skill' {
            return $FileName
        }
    }
}

function Get-PluginSubdirectory {
    <#
    .SYNOPSIS
    Returns the plugin subdirectory name for an artifact kind.

    .DESCRIPTION
    Maps a collection item kind to the corresponding subdirectory name
    within the plugin directory structure.

    .PARAMETER Kind
    The artifact kind: agent, prompt, instruction, or skill.

    .OUTPUTS
    [string] The subdirectory name (agents, commands, instructions, or skills).
    #>
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateSet('agent', 'prompt', 'instruction', 'skill')]
        [string]$Kind
    )

    switch ($Kind) {
        'agent' { return 'agents' }
        'prompt' { return 'commands' }
        'instruction' { return 'instructions' }
        'skill' { return 'skills' }
    }
}

function New-PluginManifestContent {
    <#
    .SYNOPSIS
    Generates plugin.json content as a hashtable.

    .DESCRIPTION
    Creates a hashtable representing the plugin manifest with name,
    description, and version sourced from the repository package.json.

    .PARAMETER CollectionId
    The collection identifier used as the plugin name.

    .PARAMETER Description
    A short description of the plugin.

    .PARAMETER Version
    Semantic version string from the repository package.json.

    .OUTPUTS
    [hashtable] Plugin manifest with name, description, and version keys.
    #>
    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory = $true)]
        [string]$CollectionId,

        [Parameter(Mandatory = $true)]
        [string]$Description,

        [Parameter(Mandatory = $true)]
        [string]$Version
    )

    return [ordered]@{
        name        = $CollectionId
        description = $Description
        version     = $Version
    }
}

function New-PluginReadmeContent {
    <#
    .SYNOPSIS
    Generates README.md markdown for a plugin.

    .DESCRIPTION
    Builds a complete README.md string with a markdownlint-disable header,
    title, description, install command, and tables for each artifact kind
    that has items. Only sections with items are included.

    .PARAMETER Collection
    Hashtable with id, name, and description keys from the collection manifest.

    .PARAMETER Items
    Array of processed item objects. Each object must have Name, Description,
    and Kind properties.

    .OUTPUTS
    [string] Complete README markdown content.
    #>
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory = $true)]
        [hashtable]$Collection,

        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [array]$Items
    )

    $sb = [System.Text.StringBuilder]::new()
    [void]$sb.AppendLine('<!-- markdownlint-disable-file -->')
    [void]$sb.AppendLine("# $($Collection.name)")
    [void]$sb.AppendLine()
    [void]$sb.AppendLine($Collection.description)
    [void]$sb.AppendLine()
    [void]$sb.AppendLine('## Install')
    [void]$sb.AppendLine()
    [void]$sb.AppendLine('```bash')
    [void]$sb.AppendLine("copilot plugin install $($Collection.id)@hve-core")
    [void]$sb.AppendLine('```')

    $sectionMap = [ordered]@{
        agent       = @{ Title = 'Agents'; Header = 'Agent' }
        prompt      = @{ Title = 'Commands'; Header = 'Command' }
        instruction = @{ Title = 'Instructions'; Header = 'Instruction' }
        skill       = @{ Title = 'Skills'; Header = 'Skill' }
    }

    foreach ($entry in $sectionMap.GetEnumerator()) {
        $kind = $entry.Key
        $meta = $entry.Value
        $kindItems = @($Items | Where-Object { $_.Kind -eq $kind })
        if ($kindItems.Count -eq 0) {
            continue
        }

        [void]$sb.AppendLine()
        [void]$sb.AppendLine("## $($meta.Title)")
        [void]$sb.AppendLine()
        [void]$sb.AppendLine("| $($meta.Header) | Description |")
        [void]$sb.AppendLine('| ' + ('-' * $meta.Header.Length) + ' | ----------- |')
        foreach ($item in $kindItems) {
            [void]$sb.AppendLine("| $($item.Name) | $($item.Description) |")
        }
    }

    [void]$sb.AppendLine()
    [void]$sb.AppendLine('---')
    [void]$sb.AppendLine()
    [void]$sb.AppendLine('> Source: [microsoft/hve-core](https://github.com/microsoft/hve-core)')
    [void]$sb.AppendLine()

    return $sb.ToString()
}

function New-MarketplaceManifestContent {
    <#
    .SYNOPSIS
    Generates marketplace.json content as a hashtable.

    .DESCRIPTION
    Creates a hashtable representing the marketplace manifest with repository
    metadata, owner information, and plugin entries. Matches the schema used
    by github/awesome-copilot.

    .PARAMETER RepoName
    Repository name used as the marketplace name.

    .PARAMETER Description
    Short description of the repository.

    .PARAMETER Version
    Semantic version string from package.json.

    .PARAMETER OwnerName
    Organization or individual owning the repository.

    .PARAMETER Plugins
    Array of ordered hashtables with name, description, and version keys
    from New-PluginManifestContent.

    .OUTPUTS
    [hashtable] Marketplace manifest with name, metadata, owner, and plugins keys.
    #>
    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory = $true)]
        [string]$RepoName,

        [Parameter(Mandatory = $true)]
        [string]$Description,

        [Parameter(Mandatory = $true)]
        [string]$Version,

        [Parameter(Mandatory = $true)]
        [string]$OwnerName,

        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [array]$Plugins
    )

    $pluginEntries = @()
    foreach ($plugin in $Plugins) {
        $pluginEntries += [ordered]@{
            name        = $plugin.name
            source      = "./plugins/$($plugin.name)"
            description = $plugin.description
            version     = $plugin.version
        }
    }

    return [ordered]@{
        name     = $RepoName
        metadata = [ordered]@{
            description = $Description
            version     = $Version
            pluginRoot  = './plugins'
        }
        owner    = [ordered]@{
            name = $OwnerName
        }
        plugins  = $pluginEntries
    }
}

function Write-MarketplaceManifest {
    <#
    .SYNOPSIS
    Writes the marketplace.json file to .github/plugin/.

    .DESCRIPTION
    Assembles plugin metadata from generated collections and writes the
    marketplace manifest to .github/plugin/marketplace.json. Creates the
    directory when it does not exist.

    .PARAMETER RepoRoot
    Absolute path to the repository root directory.

    .PARAMETER Collections
    Array of collection manifest hashtables with id and description.

    .PARAMETER DryRun
    When specified, logs the action without writing to disk.
    #>
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$RepoRoot,

        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [array]$Collections,

        [Parameter(Mandatory = $false)]
        [switch]$DryRun
    )

    $packageJsonPath = Join-Path -Path $RepoRoot -ChildPath 'package.json'
    $packageJson = Get-Content -Path $packageJsonPath -Raw | ConvertFrom-Json

    $plugins = @()
    foreach ($collection in ($Collections | Sort-Object { $_.id })) {
        $plugins += New-PluginManifestContent `
            -CollectionId $collection.id `
            -Description $collection.description `
            -Version $packageJson.version
    }

    $manifest = New-MarketplaceManifestContent `
        -RepoName $packageJson.name `
        -Description $packageJson.description `
        -Version $packageJson.version `
        -OwnerName $packageJson.author `
        -Plugins $plugins

    $outputDir = Join-Path -Path $RepoRoot -ChildPath '.github' -AdditionalChildPath 'plugin'
    $outputPath = Join-Path -Path $outputDir -ChildPath 'marketplace.json'

    if ($DryRun) {
        Write-Host "  [DRY RUN] Would write marketplace.json at $outputPath" -ForegroundColor Yellow
        return
    }

    if (-not (Test-Path -Path $outputDir)) {
        New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
    }

    $manifest | ConvertTo-Json -Depth 10 | Set-Content -Path $outputPath -Encoding utf8 -NoNewline
    Write-Host "  Marketplace manifest: $outputPath" -ForegroundColor Green
}

function New-GenerateResult {
    <#
    .SYNOPSIS
    Creates a standardized result object.

    .DESCRIPTION
    Returns a hashtable representing the outcome of a plugin generation run
    with success status, plugin count, and optional error message.

    .PARAMETER Success
    Whether the operation succeeded.

    .PARAMETER PluginCount
    Number of plugins generated.

    .PARAMETER ErrorMessage
    Optional error message when Success is $false.

    .OUTPUTS
    [hashtable] Result with Success, PluginCount, and ErrorMessage keys.
    #>
    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory = $true)]
        [bool]$Success,

        [Parameter(Mandatory = $true)]
        [int]$PluginCount,

        [Parameter(Mandatory = $false)]
        [string]$ErrorMessage = ''
    )

    return @{
        Success      = $Success
        PluginCount  = $PluginCount
        ErrorMessage = $ErrorMessage
    }
}

# ---------------------------------------------------------------------------
# I/O Functions (file system operations)
# ---------------------------------------------------------------------------

function New-RelativeSymlink {
    <#
    .SYNOPSIS
    Creates a relative symlink from destination to source.

    .DESCRIPTION
    Calculates the relative path from the directory containing the destination
    to the source path, then creates a symbolic link at the destination
    pointing to that relative path.

    .PARAMETER SourcePath
    Absolute path to the symlink target (the real file or directory).

    .PARAMETER DestinationPath
    Absolute path where the symlink will be created.
    #>
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$SourcePath,

        [Parameter(Mandatory = $true)]
        [string]$DestinationPath
    )

    $destinationDir = Split-Path -Parent $DestinationPath
    $relativePath = [System.IO.Path]::GetRelativePath($destinationDir, $SourcePath)

    if (-not (Test-Path -Path $destinationDir)) {
        New-Item -ItemType Directory -Path $destinationDir -Force | Out-Null
    }

    New-Item -ItemType SymbolicLink -Path $DestinationPath -Value $relativePath -Force | Out-Null
}

function Write-PluginDirectory {
    <#
    .SYNOPSIS
    Creates a complete plugin directory structure from a collection.

    .DESCRIPTION
    Builds the full plugin layout under the specified plugins directory,
    including subdirectories for agents, commands, instructions, and skills.
    Each item is symlinked from the plugin directory back to its source in
    the repository. Generates plugin.json and README.md.

    .PARAMETER Collection
    Parsed collection manifest hashtable with id, name, description, and items.

    .PARAMETER PluginsDir
    Absolute path to the root plugins output directory.

    .PARAMETER RepoRoot
    Absolute path to the repository root.

    .PARAMETER Version
    Semantic version string from the repository package.json.

    .PARAMETER DryRun
    When specified, logs actions without creating files or directories.

    .OUTPUTS
    [hashtable] Result with Success, AgentCount, CommandCount, InstructionCount,
    and SkillCount keys.
    #>
    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory = $true)]
        [hashtable]$Collection,

        [Parameter(Mandatory = $true)]
        [string]$PluginsDir,

        [Parameter(Mandatory = $true)]
        [string]$RepoRoot,

        [Parameter(Mandatory = $true)]
        [string]$Version,

        [Parameter(Mandatory = $false)]
        [switch]$DryRun
    )

    $collectionId = $Collection.id
    $pluginRoot = Join-Path -Path $PluginsDir -ChildPath $collectionId

    $counts = @{
        AgentCount       = 0
        CommandCount      = 0
        InstructionCount = 0
        SkillCount       = 0
    }

    $readmeItems = @()

    foreach ($item in $Collection.items) {
        $kind = $item.kind
        $sourcePath = Join-Path -Path $RepoRoot -ChildPath $item.path
        $subdir = Get-PluginSubdirectory -Kind $kind

        if ($kind -eq 'skill') {
            # Skills are directory symlinks; use the directory name as FileName
            $fileName = Split-Path -Leaf $item.path
            $itemName = Get-PluginItemName -FileName $fileName -Kind $kind
            $destPath = Join-Path -Path $pluginRoot -ChildPath $subdir -AdditionalChildPath $itemName
            $description = $fileName
        }
        else {
            $fileName = Split-Path -Leaf $item.path
            $itemName = Get-PluginItemName -FileName $fileName -Kind $kind
            $destPath = Join-Path -Path $pluginRoot -ChildPath $subdir -AdditionalChildPath $itemName

            # Read frontmatter from the source file for description
            $fallback = $itemName -replace '\.md$', ''
            if (Test-Path -Path $sourcePath) {
                $frontmatter = Get-ArtifactFrontmatter -FilePath $sourcePath -FallbackDescription $fallback
                $description = $frontmatter.description
            }
            else {
                $description = $fallback
                Write-Warning "Source file not found: $sourcePath"
            }
        }

        $readmeItems += @{
            Name        = $itemName -replace '\.md$', ''
            Description = $description
            Kind        = $kind
        }

        # Update counts
        switch ($kind) {
            'agent'       { $counts.AgentCount++ }
            'prompt'      { $counts.CommandCount++ }
            'instruction' { $counts.InstructionCount++ }
            'skill'       { $counts.SkillCount++ }
        }

        if ($DryRun) {
            Write-Verbose "DryRun: Would create symlink $destPath -> $sourcePath"
            continue
        }

        New-RelativeSymlink -SourcePath $sourcePath -DestinationPath $destPath
    }

    # Symlink shared resource directories (unconditional, all plugins)
    $sharedDirs = @(
        @{ Source = 'docs/templates';    Destination = 'docs/templates' }
        @{ Source = 'scripts/dev-tools'; Destination = 'scripts/dev-tools' }
        @{ Source = 'scripts/lib';       Destination = 'scripts/lib' }
    )

    foreach ($dir in $sharedDirs) {
        $sourcePath = Join-Path -Path $RepoRoot -ChildPath $dir.Source
        $destPath = Join-Path -Path $pluginRoot -ChildPath $dir.Destination

        if (-not (Test-Path -Path $sourcePath)) {
            Write-Warning "Shared directory not found: $sourcePath"
            continue
        }

        if ($DryRun) {
            Write-Verbose "DryRun: Would create shared directory symlink $destPath -> $sourcePath"
            continue
        }

        New-RelativeSymlink -SourcePath $sourcePath -DestinationPath $destPath
    }

    # Generate plugin.json
    $manifestDir = Join-Path -Path $pluginRoot -ChildPath '.github' -AdditionalChildPath 'plugin'
    $manifestPath = Join-Path -Path $manifestDir -ChildPath 'plugin.json'
    $manifest = New-PluginManifestContent -CollectionId $collectionId -Description $Collection.description -Version $Version

    if ($DryRun) {
        Write-Verbose "DryRun: Would write plugin.json at $manifestPath"
    }
    else {
        if (-not (Test-Path -Path $manifestDir)) {
            New-Item -ItemType Directory -Path $manifestDir -Force | Out-Null
        }
        $manifest | ConvertTo-Json -Depth 10 | Set-Content -Path $manifestPath -Encoding utf8 -NoNewline
    }

    # Generate README.md
    $readmePath = Join-Path -Path $pluginRoot -ChildPath 'README.md'
    $readmeContent = New-PluginReadmeContent -Collection $Collection -Items $readmeItems

    if ($DryRun) {
        Write-Verbose "DryRun: Would write README.md at $readmePath"
    }
    else {
        Set-Content -Path $readmePath -Value $readmeContent -Encoding utf8 -NoNewline
    }

    return @{
        Success          = $true
        AgentCount       = $counts.AgentCount
        CommandCount     = $counts.CommandCount
        InstructionCount = $counts.InstructionCount
        SkillCount       = $counts.SkillCount
    }
}

Export-ModuleMember -Function @(
    'Get-AllCollections',
    'Get-ArtifactFiles',
    'Get-ArtifactFrontmatter',
    'Get-CollectionManifest',
    'Get-PluginItemName',
    'Get-PluginSubdirectory',
    'New-GenerateResult',
    'New-MarketplaceManifestContent',
    'New-PluginManifestContent',
    'New-PluginReadmeContent',
    'New-RelativeSymlink',
    'Resolve-CollectionItemMaturity',
    'Test-ArtifactDeprecated',
    'Update-HveCoreAllCollection',
    'Write-MarketplaceManifest',
    'Write-PluginDirectory'
)