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.
        Native failures write a PowerShell error with the exit code and diagnostics
        and emit no plugins. Use -ErrorAction Stop to terminate on failure.
        A successful empty list emits no plugins and no error.
    .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 = '*'
    )
    $output = Invoke-CopilotDiscovery -Arguments 'plugin', 'list'
    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 {
        $updateName = if ($PSCmdlet.ParameterSetName -eq 'ByName') { $Name } else { $InputObject.FullName }
        Assert-CopilotShimArgument -Value $updateName -ParameterName 'Name'

        $exe = Resolve-CliExe -Name copilot
        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.
        If discovery of already-installed plugins fails, terminates without installing.
    .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') {
            Assert-CopilotShimArgument -Value $InputObject.Name -ParameterName 'InputObject.Name' -Pattern '^[A-Za-z0-9][A-Za-z0-9._#/-]*$'
            Assert-CopilotShimArgument -Value $InputObject.Marketplace -ParameterName 'InputObject.Marketplace' -Pattern '^[A-Za-z0-9][A-Za-z0-9._#/-]*$'
            "$($InputObject.Name)@$($InputObject.Marketplace)"
        } else {
            $Source
        }
        Assert-CopilotShimArgument -Value $installSource -ParameterName '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 -ErrorAction Stop | 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 {
        $uninstallName = if ($PSCmdlet.ParameterSetName -eq 'ByName') { $Name } else { $InputObject.FullName }
        Assert-CopilotShimArgument -Value $uninstallName -ParameterName 'Name'

        $exe = Resolve-CliExe -Name copilot
        if ($PSCmdlet.ShouldProcess($uninstallName, 'copilot plugin uninstall')) {
            & $exe plugin uninstall $uninstallName 2>&1
            if ($LASTEXITCODE -ne 0) {
                Write-Error "Failed to uninstall plugin: $uninstallName"
            }
        }
    }
}