Public/Plugins.ps1

function Get-CopilotPlugin {
    <#
    .SYNOPSIS
        List installed Copilot CLI plugins.
    .DESCRIPTION
        Parses the output of 'copilot plugin list' into typed CopilotPlugin objects
        with Name, Marketplace, and Version properties.
    .PARAMETER Name
        Filter by plugin name. Supports wildcards.
    .EXAMPLE
        Get-CopilotPlugin
        Lists all installed plugins.
    .EXAMPLE
        Get-CopilotPlugin dotnet*
        Lists plugins whose name starts with 'dotnet'.
    .EXAMPLE
        Get-CopilotPlugin | Where-Object Marketplace
        Lists only marketplace-sourced plugins.
    #>

    [OutputType('CopilotPlugin')]
    [CmdletBinding()]
    param(
        [Parameter(Position = 0)]
        [string]$Name = '*'
    )
    $exe = Resolve-CliExe -Name copilot
    $output = & $exe plugin list 2>&1
    foreach ($line in $output) {
        if ($line -match '^\s+[•]\s+(.+?)\s+\(v(.+?)\)\s*$') {
            $fullName = $Matches[1]
            $version = $Matches[2]
            $pluginName = $fullName
            $marketplace = ''
            if ($fullName -match '^(.+)@(.+)$') {
                $pluginName = $Matches[1]
                $marketplace = $Matches[2]
            }
            $plugin = [PSCustomObject]@{
                PSTypeName  = 'CopilotPlugin'
                Name        = $pluginName
                FullName    = $fullName
                Marketplace = $marketplace
                Version     = $version
            }
            if ($plugin.Name -like $Name -or $plugin.FullName -like $Name) {
                $plugin
            }
        }
    }
}

function Update-CopilotPlugin {
    <#
    .SYNOPSIS
        Update installed Copilot CLI plugins to the latest version.
    .DESCRIPTION
        Calls 'copilot plugin update <name>' for each plugin. Accepts pipeline
        input from Get-CopilotPlugin or explicit plugin names.
 
        When the update fails with EBUSY (file lock), retries once after a short
        delay and warns about running Copilot sessions that may hold locks.
    .PARAMETER InputObject
        A CopilotPlugin object from Get-CopilotPlugin.
    .PARAMETER Name
        The plugin name to update. For marketplace plugins, use the full
        'plugin@marketplace' format.
    .EXAMPLE
        Get-CopilotPlugin | Update-CopilotPlugin
        Updates all installed plugins.
    .EXAMPLE
        Update-CopilotPlugin -Name my-plugin
        Updates a specific plugin by name.
    #>

    [OutputType('CopilotPluginUpdateResult')]
    [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'ByObject')]
    param(
        [Parameter(ParameterSetName = 'ByObject', ValueFromPipeline, Mandatory)]
        [PSObject]$InputObject,

        [Parameter(ParameterSetName = 'ByName', Position = 0, Mandatory)]
        [string]$Name
    )
    process {
        $exe = Resolve-CliExe -Name copilot
        $updateName = if ($PSCmdlet.ParameterSetName -eq 'ByName') { $Name } else { $InputObject.FullName }
        if (-not $PSCmdlet.ShouldProcess($updateName, 'copilot plugin update')) {
            return
        }

        Write-Verbose "Updating plugin: $updateName"
        $output = & $exe plugin update $updateName 2>&1
        $success = $LASTEXITCODE -eq 0
        $errorMsg = $null

        if (-not $success) {
            $errorMsg = ($output | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] -or $_ -match 'Failed|Error' }) -join '; '
            if (-not $errorMsg) { $errorMsg = ($output | Out-String).Trim() }

            # Retry once on EBUSY (file lock from running sessions)
            if ($errorMsg -match 'EBUSY') {
                Write-Verbose "EBUSY detected for $updateName — retrying in 2 seconds..."
                Start-Sleep -Seconds 2
                $output = & $exe plugin update $updateName 2>&1
                $success = $LASTEXITCODE -eq 0
                if (-not $success) {
                    $errorMsg = ($output | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] -or $_ -match 'Failed|Error' }) -join '; '
                    if (-not $errorMsg) { $errorMsg = ($output | Out-String).Trim() }
                } else {
                    $errorMsg = $null
                }
            }
        }

        if (-not $success) {
            if ($errorMsg -match 'EBUSY') {
                $sessionPids = Get-Process -Name copilot -ErrorAction SilentlyContinue |
                    Where-Object { $_.Id -ne $PID } |
                    ForEach-Object {
                        $cmdLine = (Get-CimInstance Win32_Process -Filter "ProcessId=$($_.Id)" -ErrorAction SilentlyContinue).CommandLine
                        $sessionId = if ($cmdLine -match '--resume\s+(\S+)') { $Matches[1].Substring(0, 8) } else { $null }
                        "PID $($_.Id)$(if ($sessionId) { " (session $sessionId)" })"
                    }
                $pidList = ($sessionPids | Select-Object -Unique) -join ', '
                Write-Warning "Failed to update $updateName — plugin directory is locked by running sessions. Close other sessions and retry. Running: $pidList"
            } else {
                Write-Warning "Failed to update plugin: $updateName — $errorMsg"
            }
        }

        [PSCustomObject]@{
            PSTypeName = 'CopilotPluginUpdateResult'
            Name       = $updateName
            Success    = $success
            Error      = $errorMsg
        }
    }
}

function Install-CopilotPlugin {
    <#
    .SYNOPSIS
        Install a Copilot CLI plugin.
    .DESCRIPTION
        Installs a plugin from a GitHub repository, marketplace, or direct URL.
    .PARAMETER Source
        The plugin source: owner/repo (GitHub), plugin@marketplace, or a URL.
    .PARAMETER InputObject
        A marketplace plugin object (e.g. from Get-CopilotMarketplacePlugin) to
        install, accepted from the pipeline.
    .EXAMPLE
        Install-CopilotPlugin -Source shmuelie/shmuelie-skills
        Installs a plugin from a GitHub repository.
    .EXAMPLE
        Install-CopilotPlugin -Source dotnet@dotnet-agent-skills
        Installs a plugin from a registered marketplace.
    .EXAMPLE
        Get-CopilotMarketplacePlugin dotnet-agent-skills | Install-CopilotPlugin
        Installs all plugins from the dotnet-agent-skills marketplace.
    #>

    [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'BySource')]
    param(
        [Parameter(ParameterSetName = 'BySource', Position = 0, Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$Source,

        [Parameter(ParameterSetName = 'ByObject', Mandatory, ValueFromPipeline)]
        [PSObject]$InputObject
    )
    process {
        $installSource = if ($PSCmdlet.ParameterSetName -eq 'ByObject') {
            "$($InputObject.Name)@$($InputObject.Marketplace)"
        } else {
            $Source
        }
        $exe = Resolve-CliExe -Name copilot
        if ($PSCmdlet.ShouldProcess($installSource, 'copilot plugin install')) {
            # Skip if already installed.
            $checkName = if ($installSource -match '^(.+)@') { $Matches[1] }
                         elseif ($installSource -match '/([^/#]+)(?:#|$)') { $Matches[1] }
                         else { $installSource }
            $existing = Get-CopilotPlugin | Where-Object { $_.Name -eq $checkName -or $_.FullName -eq $installSource }
            if ($existing) {
                Write-Verbose "Plugin '$($existing.FullName)' is already installed."
                return
            }
            & $exe plugin install $installSource 2>&1
            if ($LASTEXITCODE -ne 0) {
                Write-Error "Failed to install plugin: $installSource"
            }
        }
    }
}

function Uninstall-CopilotPlugin {
    <#
    .SYNOPSIS
        Uninstall a Copilot CLI plugin.
    .DESCRIPTION
        Removes an installed plugin by name. Accepts pipeline input from Get-CopilotPlugin.
    .PARAMETER InputObject
        A CopilotPlugin object from Get-CopilotPlugin.
    .PARAMETER Name
        The plugin name to uninstall.
    .EXAMPLE
        Uninstall-CopilotPlugin -Name my-plugin
        Uninstalls the specified plugin.
    .EXAMPLE
        Get-CopilotPlugin old-plugin | Uninstall-CopilotPlugin
        Uninstalls via pipeline.
    #>

    [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'ByObject')]
    param(
        [Parameter(ParameterSetName = 'ByObject', ValueFromPipeline, Mandatory)]
        [PSObject]$InputObject,

        [Parameter(ParameterSetName = 'ByName', Position = 0, Mandatory)]
        [string]$Name
    )
    process {
        $exe = Resolve-CliExe -Name copilot
        $uninstallName = if ($PSCmdlet.ParameterSetName -eq 'ByName') { $Name } else { $InputObject.FullName }
        if ($PSCmdlet.ShouldProcess($uninstallName, 'copilot plugin uninstall')) {
            & $exe plugin uninstall $uninstallName 2>&1
            if ($LASTEXITCODE -ne 0) {
                Write-Error "Failed to uninstall plugin: $uninstallName"
            }
        }
    }
}