Plugins.ps1
|
function New-CopilotPluginRecord { param( [Parameter(Mandatory)] [string]$Name, [AllowEmptyString()] [string]$Marketplace = '', [AllowEmptyString()] [string]$Version = '', [Parameter(Mandatory)] [bool]$Enabled, [AllowEmptyString()] [string]$Source = '', [AllowEmptyString()] [string]$InstalledFrom = '' ) $fullName = if ($Marketplace) { "$Name@$Marketplace" } else { $Name } [PSCustomObject]@{ PSTypeName = 'CopilotPlugin' Name = $Name FullName = $fullName Marketplace = $Marketplace Version = $Version Enabled = $Enabled Source = $Source InstalledFrom = $InstalledFrom Managed = -not [string]::Equals($Source, 'builtin', [StringComparison]::OrdinalIgnoreCase) -and -not [string]::Equals($Source, 'plugin-dir', [StringComparison]::OrdinalIgnoreCase) } } function Get-CopilotPluginJsonStringProperty { param( [Parameter(Mandatory)] [System.Collections.IDictionary]$Entry, [Parameter(Mandatory)] [string]$Name, [switch]$Required ) if (-not $Entry.Contains($Name) -or $null -eq $Entry[$Name]) { if ($Required) { throw "Missing required '$Name' property." } return '' } if ($Entry[$Name] -isnot [string]) { throw "Property '$Name' must be a string." } $value = [string]$Entry[$Name] if ($Required -and [string]::IsNullOrWhiteSpace($value)) { throw "Property '$Name' must not be empty." } $value } function ConvertFrom-CopilotPluginJsonOutput { param( [Parameter(Mandatory)] [AllowEmptyCollection()] [string[]]$Output ) $json = ($Output | ForEach-Object { "$_" }) -join [Environment]::NewLine if ([string]::IsNullOrWhiteSpace($json)) { return @() } try { $parsed = $json | ConvertFrom-Json -AsHashtable -NoEnumerate -Depth 16 } catch { throw "Invalid JSON. $($_.Exception.Message)" } if ($parsed -isnot [System.Collections.IList]) { throw 'The JSON result must be an array.' } foreach ($entry in $parsed) { if ($entry -isnot [System.Collections.IDictionary]) { throw 'Each plugin row must be an object.' } if (-not $entry.Contains('enabled') -or $entry.enabled -isnot [bool]) { throw "Property 'enabled' must be a Boolean." } New-CopilotPluginRecord -Name (Get-CopilotPluginJsonStringProperty -Entry $entry -Name 'name' -Required) ` -Marketplace (Get-CopilotPluginJsonStringProperty -Entry $entry -Name 'marketplace') ` -Version (Get-CopilotPluginJsonStringProperty -Entry $entry -Name 'version') ` -Enabled ([bool]$entry.enabled) ` -Source (Get-CopilotPluginJsonStringProperty -Entry $entry -Name 'source' -Required) ` -InstalledFrom (Get-CopilotPluginJsonStringProperty -Entry $entry -Name 'installedFrom') } } function ConvertFrom-CopilotPluginTextOutput { param( [Parameter(Mandatory)] [AllowEmptyCollection()] [string[]]$Output ) $section = 'managed' foreach ($line in $Output) { switch -Regex ($line) { '^\s*Built-in plugins:\s*$' { $section = 'builtin' continue } '^\s*External Plugins \(via --plugin-dir\):\s*$' { $section = 'plugin-dir' continue } '^\s*$' { $section = 'managed' continue } } if ($line -match '^\s+[•]\s+(.+?)\s+\(v(.+?)\)(?:\s+\[(disabled)\])?\s*$') { $fullName = $Matches[1] $version = $Matches[2] $enabled = $Matches[3] -ne 'disabled' $pluginName = $fullName $marketplace = '' if ($fullName -match '^(.+)@(.+)$') { $pluginName = $Matches[1] $marketplace = $Matches[2] } $source = if ($section -eq 'managed' -and $marketplace) { 'marketplace' } else { $section } New-CopilotPluginRecord -Name $pluginName -Marketplace $marketplace -Version $version -Enabled $enabled -Source $source } } } function Test-CopilotPluginListJsonUnsupported { param( [Parameter(Mandatory)] [pscustomobject]$Result ) if ($Result.ExitCode -isnot [int] -or $Result.ExitCode -eq 0) { return $false } $diagnostics = ($Result.Output | ForEach-Object { "$_" }) -join [Environment]::NewLine [bool]($diagnostics -match '(?is)(?:unexpected|unknown|unrecognized|invalid)[^\r\n]*--json|--json[^\r\n]*(?:unexpected|unknown|unrecognized|invalid)') } function Get-CopilotPluginInstallCheckName { param( [Parameter(Mandatory)] [string]$Source ) if ($Source -match '^[^@]+@[^@]+$') { return ($Source -split '@', 2)[0] } if ($Source -match '^[^:]+/[^:]+:(.+)$') { $pluginPath = $Matches[1].TrimEnd('\', '/') $leaf = [IO.Path]::GetFileName($pluginPath) if ($leaf) { return $leaf } } $uri = $null if ([Uri]::TryCreate($Source, [UriKind]::Absolute, [ref]$uri)) { $fileName = [IO.Path]::GetFileName($uri.AbsolutePath.TrimEnd('/')) if ($fileName) { return ($fileName -replace '\.(zip|tgz|tar\.gz|git)$', '') } } $trimmedSource = $Source.TrimEnd('\', '/') $fileLeaf = [IO.Path]::GetFileName($trimmedSource) if ($fileLeaf) { return ($fileLeaf -replace '\.(zip|tgz|tar\.gz|git)$', '') } if ($Source -match '/([^/#]+?)(?:\.git)?(?:#|$)') { return $Matches[1] } $Source } function Resolve-CopilotPluginInstallRequest { param( [Parameter(Mandatory)] [string]$Source ) if ($Source -match '^(?<name>[^@]+)@(?<marketplace>[^@]+)$') { return [pscustomobject]@{ Kind = 'Marketplace' Name = $Matches['name'] FullName = $Source Marketplace = $Matches['marketplace'] } } [pscustomobject]@{ Kind = 'Direct' Name = Get-CopilotPluginInstallCheckName -Source $Source FullName = $Source Marketplace = '' InstalledFrom = $Source } } function Get-CopilotUnmanagedPluginMessage { param( [Parameter(Mandatory)] [string]$Name, [AllowEmptyString()] [string]$Source ) $reason = if ([string]::Equals($Source, 'plugin-dir', [StringComparison]::OrdinalIgnoreCase)) { 'it is mounted via --plugin-dir' } elseif ([string]::Equals($Source, 'builtin', [StringComparison]::OrdinalIgnoreCase)) { 'it is built into the Copilot CLI' } else { "its source '$Source' is not a managed installation" } "Cannot manage plugin '$Name' with this cmdlet because $reason." } function Get-CopilotPlugin { <# .SYNOPSIS List installed Copilot CLI plugins. .DESCRIPTION Uses 'copilot plugin list --json' when available and falls back to the legacy text list on older CLIs. Returns typed CopilotPlugin objects with Name, FullName, Marketplace, Version, Enabled, Source, InstalledFrom, and Managed 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. Invalid JSON or schema failures also write a PowerShell error and emit no plugins. .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 = '*' ) $jsonResult = Invoke-CopilotCliCapture -Arguments @('plugin', 'list', '--json') $plugins = if ($jsonResult.ExitCode -is [int] -and $jsonResult.ExitCode -eq 0) { try { @(ConvertFrom-CopilotPluginJsonOutput -Output $jsonResult.Output) } catch { $jsonText = ($jsonResult.Output | ForEach-Object { "$_" }) -join [Environment]::NewLine $message = "copilot plugin list --json returned an invalid result. $($_.Exception.Message)" $errorRecord = [System.Management.Automation.ErrorRecord]::new( [System.InvalidOperationException]::new($message, $_.Exception), 'CopilotDiscoveryInvalidResult', [System.Management.Automation.ErrorCategory]::InvalidData, $jsonText) $PSCmdlet.WriteError($errorRecord) return } } elseif (Test-CopilotPluginListJsonUnsupported -Result $jsonResult) { @(ConvertFrom-CopilotPluginTextOutput -Output (Invoke-CopilotDiscovery -Arguments @('plugin', 'list'))) } else { Write-CopilotDiscoveryFailure -Arguments @('plugin', 'list', '--json') -Result $jsonResult return } foreach ($plugin in $plugins) { 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' if ($PSCmdlet.ParameterSetName -eq 'ByObject' -and $InputObject.PSObject.Properties['Managed'] -and -not [bool]$InputObject.Managed) { $errorMsg = Get-CopilotUnmanagedPluginMessage -Name $updateName -Source ([string]$InputObject.Source) Write-Warning $errorMsg [PSCustomObject]@{ PSTypeName = 'CopilotPluginUpdateResult' Name = $updateName Success = $false Error = $errorMsg } return } $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. Duplicate detection uses the installed plugin's full managed identity when the Copilot CLI reports it, so different marketplaces and unmanaged --plugin-dir mounts are not confused with the requested source. .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')) { $requestedPlugin = Resolve-CopilotPluginInstallRequest -Source $installSource $installedPlugins = @(Get-CopilotPlugin -ErrorAction Stop) $existing = switch ($requestedPlugin.Kind) { 'Marketplace' { @($installedPlugins | Where-Object { $_.Managed -and $_.FullName -eq $requestedPlugin.FullName }) break } 'Direct' { $exact = @($installedPlugins | Where-Object { $_.Managed -and $_.InstalledFrom -and $_.InstalledFrom -eq $requestedPlugin.InstalledFrom }) if ($exact) { $exact break } $legacy = @($installedPlugins | Where-Object { -not $_.Source -and ($_.Name -eq $requestedPlugin.Name -or $_.FullName -eq $installSource) }) if ($legacy) { $legacy break } $ambiguous = @($installedPlugins | Where-Object { $_.Managed -and -not $_.Marketplace -and $_.Name -eq $requestedPlugin.Name -and -not $_.InstalledFrom }) if ($ambiguous) { throw "Cannot determine whether plugin '$($requestedPlugin.Name)' is already installed because the Copilot CLI did not report its install source." } @() break } default { @() } } 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. Built-in plugins and --plugin-dir mounts are reported by discovery but rejected from the object pipeline because they are not managed installations. .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' if ($PSCmdlet.ParameterSetName -eq 'ByObject' -and $InputObject.PSObject.Properties['Managed'] -and -not [bool]$InputObject.Managed) { Write-Error (Get-CopilotUnmanagedPluginMessage -Name $uninstallName -Source ([string]$InputObject.Source)) return } $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" } } } } |