CopilotAtelier.psm1
|
#Region './Private/ConvertFrom-Jsonc.ps1' -1 function ConvertFrom-Jsonc { <# .SYNOPSIS Parses a JSONC document into an object. .DESCRIPTION VS Code writes its configuration files as JSONC: JSON with line comments, block comments, and trailing commas. ConvertFrom-Json rejects all three, so the text is normalised before it is parsed. A document that is empty once the comments are removed returns $null rather than raising an error. .PARAMETER Text The raw JSONC document to parse. .OUTPUTS System.Management.Automation.PSObject .EXAMPLE ConvertFrom-Jsonc -Text (Get-Content -Path settings.json -Raw) Parses a VS Code settings file that contains comments. #> [CmdletBinding()] [OutputType([System.Management.Automation.PSObject])] param ( [Parameter(Mandatory = $true)] [AllowEmptyString()] [System.String] $Text ) $cleaned = $Text -replace '(?m)^\s*//.*$', '' $cleaned = $cleaned -replace '/\*[\s\S]*?\*/', '' $cleaned = $cleaned -replace ',(\s*[}\]])', '$1' if ([System.String]::IsNullOrWhiteSpace($cleaned)) { return $null } return ($cleaned | ConvertFrom-Json) } #EndRegion './Private/ConvertFrom-Jsonc.ps1' 47 #Region './Private/Get-CopilotAtelierContentPath.ps1' -1 function Get-CopilotAtelierContentPath { <# .SYNOPSIS Resolves the directory that holds the customization content. .DESCRIPTION The customization directories ship inside the module, so an installed module deploys from its own module base. When the source files are dot-sourced from a repository clone there is no module context and the repository root is used instead. .OUTPUTS System.String .EXAMPLE Get-CopilotAtelierContentPath Returns the module base, or the repository root during development. #> [CmdletBinding()] [OutputType([System.String])] param () $module = $ExecutionContext.SessionState.Module if ($module -and -not [System.String]::IsNullOrWhiteSpace($module.ModuleBase)) { return $module.ModuleBase } # Dot-sourced from source/Private during development. return (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) } #EndRegion './Private/Get-CopilotAtelierContentPath.ps1' 35 #Region './Private/Get-CopilotAtelierPath.ps1' -1 function Get-CopilotAtelierPath { <# .SYNOPSIS Resolves every filesystem path the installer works with. .DESCRIPTION Determines the user profile, the VS Code user configuration directory, and the canonical customization target for the current platform. OneDrive is preferred for the target so a single synced copy serves every machine; the user profile is the fallback. When both a consumer and a commercial OneDrive are present the caller is asked which one to use. .PARAMETER TargetName The folder name used for the canonical target. Defaults to CopilotAtelier, which is also the module name. .OUTPUTS System.Management.Automation.PSCustomObject .EXAMPLE Get-CopilotAtelierPath Returns the resolved profile, settings, and canonical target paths. #> [CmdletBinding()] [OutputType([System.Management.Automation.PSCustomObject])] param ( [Parameter()] [ValidateNotNullOrEmpty()] [System.String] $TargetName = 'CopilotAtelier' ) $isWindowsPlatform = [System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT $isMacOSPlatform = $false $isMacOSVariable = Get-Variable -Name IsMacOS -ErrorAction SilentlyContinue if ($isMacOSVariable) { $isMacOSPlatform = [System.Boolean] $isMacOSVariable.Value } if ($isWindowsPlatform -and -not [System.String]::IsNullOrWhiteSpace($env:USERPROFILE)) { $userHome = $env:USERPROFILE } elseif (-not [System.String]::IsNullOrWhiteSpace($env:HOME)) { $userHome = $env:HOME } else { $userHome = [System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::UserProfile) } if ([System.String]::IsNullOrWhiteSpace($userHome)) { throw 'Unable to resolve the current user profile directory.' } if ($isWindowsPlatform) { $configRoot = $env:APPDATA if ([System.String]::IsNullOrWhiteSpace($configRoot)) { $configRoot = [System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::ApplicationData) } } elseif ($isMacOSPlatform) { $configRoot = Join-Path -Path $userHome -ChildPath 'Library/Application Support' } elseif (-not [System.String]::IsNullOrWhiteSpace($env:XDG_CONFIG_HOME)) { $configRoot = $env:XDG_CONFIG_HOME } else { $configRoot = Join-Path -Path $userHome -ChildPath '.config' } if ([System.String]::IsNullOrWhiteSpace($configRoot)) { throw 'Unable to resolve the VS Code configuration directory.' } $oneDriveCandidate = [ordered] @{} if ($env:OneDriveConsumer -and (Test-Path -LiteralPath $env:OneDriveConsumer)) { $oneDriveCandidate['Consumer'] = $env:OneDriveConsumer } if ($env:OneDriveCommercial -and (Test-Path -LiteralPath $env:OneDriveCommercial)) { $oneDriveCandidate['Commercial'] = $env:OneDriveCommercial } $oneDriveRoot = $null if ($oneDriveCandidate.Count -gt 1) { $choice = @($oneDriveCandidate.Keys) $promptLine = for ($index = 0; $index -lt $choice.Count; $index++) { " [$($index + 1)] $($choice[$index]) - $($oneDriveCandidate[$choice[$index]])" } $prompt = @( 'Multiple OneDrive accounts detected:' $promptLine "Select OneDrive account (1-$($choice.Count))" ) -join [System.Environment]::NewLine do { $selection = Read-Host -Prompt $prompt } while ($selection -notmatch '^\d+$' -or [System.Int32] $selection -lt 1 -or [System.Int32] $selection -gt $choice.Count) $oneDriveRoot = $oneDriveCandidate[$choice[[System.Int32] $selection - 1]] } elseif ($oneDriveCandidate.Count -eq 1) { $oneDriveRoot = @($oneDriveCandidate.Values)[0] } elseif ($env:OneDrive -and (Test-Path -LiteralPath $env:OneDrive)) { $oneDriveRoot = $env:OneDrive } else { $defaultOneDrivePath = Join-Path -Path $userHome -ChildPath 'OneDrive' if (Test-Path -LiteralPath $defaultOneDrivePath) { $oneDriveRoot = $defaultOneDrivePath } } $targetPath = if ($oneDriveRoot) { Join-Path -Path $oneDriveRoot -ChildPath $TargetName } else { Join-Path -Path $userHome -ChildPath $TargetName } $settingsDirectory = Join-Path -Path (Join-Path -Path $configRoot -ChildPath 'Code') -ChildPath 'User' $linkItemType = 'SymbolicLink' if ($isWindowsPlatform) { $linkItemType = 'Junction' } return [PSCustomObject] @{ UserHome = $userHome ConfigRoot = $configRoot SettingsDirectory = $settingsDirectory SettingsPath = Join-Path -Path $settingsDirectory -ChildPath 'settings.json' KeybindingsPath = Join-Path -Path $settingsDirectory -ChildPath 'keybindings.json' OneDriveRoot = $oneDriveRoot TargetName = $TargetName TargetPath = $targetPath DeploymentManifestPath = Join-Path -Path $targetPath -ChildPath '.copilotatelier.json' LegacyLocalPath = Join-Path -Path $userHome -ChildPath $TargetName CopilotRoot = Join-Path -Path $userHome -ChildPath '.copilot' IsWindowsPlatform = $isWindowsPlatform LinkItemType = $linkItemType } } #EndRegion './Private/Get-CopilotAtelierPath.ps1' 181 #Region './Private/Get-KeybindingKey.ps1' -1 function Get-KeybindingKey { <# .SYNOPSIS Builds the identity tuple of a VS Code keybinding. .DESCRIPTION Keybindings have no identifier, so the merge treats the combination of key, command, and when clause as the identity. Two bindings that produce the same tuple are the same binding. .PARAMETER Binding The parsed keybinding entry. .OUTPUTS System.String .EXAMPLE Get-KeybindingKey -Binding $binding Returns a comparable identity string such as 'ctrl+k|editor.action|'. #> [CmdletBinding()] [OutputType([System.String])] param ( [Parameter(Mandatory = $true)] [System.Management.Automation.PSObject] $Binding ) $key = if ($Binding.PSObject.Properties['key']) { [System.String] $Binding.key } else { '' } $command = if ($Binding.PSObject.Properties['command']) { [System.String] $Binding.command } else { '' } $when = if ($Binding.PSObject.Properties['when']) { [System.String] $Binding.when } else { '' } return "$key|$command|$when" } #EndRegion './Private/Get-KeybindingKey.ps1' 61 #Region './Private/Merge-LocationSetting.ps1' -1 function Merge-LocationSetting { <# .SYNOPSIS Merges entries into a VS Code location-map setting. .DESCRIPTION Location-map settings such as chat.promptFilesLocations are objects whose property names are paths. The merge preserves every path the user added by hand and only adds or overwrites the requested keys. .PARAMETER Settings The parsed settings object to update in place. .PARAMETER PropertyName The name of the location-map setting to merge into. .PARAMETER NewEntry The path-to-value entries to add or overwrite. .OUTPUTS None. .EXAMPLE Merge-LocationSetting -Settings $settings -PropertyName 'chat.promptFilesLocations' -NewEntry @{ '~/.copilot/prompts' = $true } Registers the prompt discovery path without discarding user entries. #> [CmdletBinding()] [OutputType([System.Void])] param ( [Parameter(Mandatory = $true)] [System.Management.Automation.PSObject] $Settings, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [System.String] $PropertyName, [Parameter(Mandatory = $true)] [ValidateNotNull()] [System.Collections.Hashtable] $NewEntry ) $merged = [ordered] @{} if ($Settings.PSObject.Properties[$PropertyName]) { foreach ($property in $Settings.$PropertyName.PSObject.Properties) { $merged[$property.Name] = $property.Value } } foreach ($key in $NewEntry.Keys) { $merged[$key] = $NewEntry[$key] } $Settings | Add-Member -NotePropertyName $PropertyName -NotePropertyValue ([PSCustomObject] $merged) -Force } #EndRegion './Private/Merge-LocationSetting.ps1' 66 #Region './Private/Remove-LocationSettingEntry.ps1' -1 function Remove-LocationSettingEntry { <# .SYNOPSIS Removes named entries from a VS Code location-map setting. .DESCRIPTION Earlier releases registered the customization directories through chat.*FilesLocations. Those entries now duplicate the ~/.copilot discovery links, so they are removed while unrelated user-defined paths are preserved. A location map left empty is removed entirely. .PARAMETER Settings The parsed settings object to update in place. .PARAMETER PropertyName The name of the location-map setting to clean up. .PARAMETER Entry The path entries to remove. .OUTPUTS None. .EXAMPLE Remove-LocationSettingEntry -Settings $settings -PropertyName 'chat.agentFilesLocations' -Entry '~/CopilotAtelier/Agents' Drops one obsolete agent location and keeps every other entry. #> [CmdletBinding(SupportsShouldProcess = $true)] [OutputType([System.Void])] param ( [Parameter(Mandatory = $true)] [System.Management.Automation.PSObject] $Settings, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [System.String] $PropertyName, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [System.String[]] $Entry ) $property = $Settings.PSObject.Properties[$PropertyName] if (-not $property) { return } foreach ($entryName in $Entry) { if ($PSCmdlet.ShouldProcess($PropertyName, "Remove location entry '$entryName'")) { $property.Value.PSObject.Properties.Remove($entryName) } } if (@($property.Value.PSObject.Properties).Count -eq 0) { $Settings.PSObject.Properties.Remove($PropertyName) } } #EndRegion './Private/Remove-LocationSettingEntry.ps1' 69 #Region './Private/Set-CustomizationLink.ps1' -1 function Set-CustomizationLink { <# .SYNOPSIS Points a discovery path at a customization directory. .DESCRIPTION VS Code, the GitHub Copilot CLI, and Claude Code discover customizations through well-known user-level folders. This command replaces such a folder with a junction on Windows or a symbolic link elsewhere so every client reads the single canonical target. An existing link is recreated so it always points at the current target. An empty real directory is removed silently. A non-empty real directory is only replaced after the caller confirms, and its contents are merged into the target first without overwriting files already there. .PARAMETER LinkPath The discovery path to create or refresh. .PARAMETER TargetPath The customization directory the link resolves to. .PARAMETER LinkItemType The link flavour to create: Junction on Windows, SymbolicLink elsewhere. .PARAMETER CreateOnly Leaves an existing path untouched. Used for third-party roots such as ~/.claude that belong to another tool and must never be adopted, merged, or repointed. .OUTPUTS None. .EXAMPLE Set-CustomizationLink -LinkPath ~/.copilot/skills -TargetPath ~/OneDrive/CopilotAtelier/Skills -LinkItemType Junction Points the Copilot skill discovery folder at the canonical target. #> [CmdletBinding(SupportsShouldProcess = $true)] [OutputType([System.Void])] param ( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [System.String] $LinkPath, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [System.String] $TargetPath, [Parameter(Mandatory = $true)] [ValidateSet('Junction', 'SymbolicLink')] [System.String] $LinkItemType, [Parameter()] [System.Management.Automation.SwitchParameter] $CreateOnly ) if (-not (Test-Path -LiteralPath $TargetPath)) { Write-Information -MessageData "Skipped link: target missing - $TargetPath" return } $linkParent = Split-Path -Parent $LinkPath if (-not (Test-Path -LiteralPath $linkParent)) { New-Item -ItemType Directory -Path $linkParent -Force | Out-Null Write-Information -MessageData "Created: $linkParent" } if (Test-Path -LiteralPath $LinkPath) { if ($CreateOnly) { Write-Information -MessageData "Skipped link: '$LinkPath' already exists and belongs to another tool." return } $item = Get-Item -LiteralPath $LinkPath -Force $isLink = $item.Attributes.HasFlag([System.IO.FileAttributes]::ReparsePoint) if ($isLink) { <# Stale or correct link: remove and recreate so it always points at the current target. Never use -Recurse on a reparse point, because Windows PowerShell follows the link and deletes the target tree. #> if ($PSCmdlet.ShouldProcess($LinkPath, 'Remove existing link')) { try { [System.IO.Directory]::Delete($LinkPath, $false) } catch { Remove-Item -LiteralPath $LinkPath -Force } Write-Information -MessageData "Removed existing link: $LinkPath" } } else { $children = @(Get-ChildItem -LiteralPath $LinkPath -Force -ErrorAction SilentlyContinue) if (-not $children) { if ($PSCmdlet.ShouldProcess($LinkPath, 'Remove empty directory')) { Remove-Item -LiteralPath $LinkPath -Force Write-Information -MessageData "Removed empty directory: $LinkPath" } } else { Write-Information -MessageData '' Write-Information -MessageData "Directory '$LinkPath' is not empty ($($children.Count) item(s))." Write-Information -MessageData "It must be replaced with a link to '$TargetPath'." if (-not $PSCmdlet.ShouldProcess($LinkPath, "Merge into '$TargetPath' and remove")) { return } $answer = Read-Host -Prompt 'Copy its contents into the target and then delete it? [y/N]' if ($answer -notmatch '^(y|yes)$') { Write-Information -MessageData "Skipped link: user declined to remove '$LinkPath'." return } foreach ($child in $children) { $destinationChild = Join-Path -Path $TargetPath -ChildPath $child.Name if (Test-Path -LiteralPath $destinationChild) { Write-Information -MessageData " Skip (already present in target): $($child.Name)" continue } Copy-Item -LiteralPath $child.FullName -Destination $destinationChild -Recurse -Force Write-Information -MessageData " Copied: $($child.Name) -> $TargetPath" } Remove-Item -LiteralPath $LinkPath -Recurse -Force Write-Information -MessageData "Removed: $LinkPath" } } } if ($PSCmdlet.ShouldProcess($LinkPath, "Create $LinkItemType to '$TargetPath'")) { New-Item -ItemType $LinkItemType -Path $LinkPath -Target $TargetPath | Out-Null Write-Information -MessageData "${LinkItemType}: $LinkPath -> $TargetPath" } } #EndRegion './Private/Set-CustomizationLink.ps1' 178 #Region './Public/Get-CopilotAtelierVersion.ps1' -1 function Get-CopilotAtelierVersion { <# .SYNOPSIS Reports the installed module version and the deployed customization version. .DESCRIPTION Compares the version of the currently loaded CopilotAtelier module with the version recorded in the canonical target the last time Install-CopilotAtelier ran. Use it to find out whether a module update still needs to be deployed to the discovery folders. DeployedVersion is null when the target has never been written, and Version is null when the commands were dot-sourced from a repository clone instead of imported as a module. .OUTPUTS System.Management.Automation.PSCustomObject .EXAMPLE Get-CopilotAtelierVersion Returns the module version, the deployed version, and whether they match. .LINK https://github.com/raandree/CopilotAtelier #> [CmdletBinding()] [OutputType([System.Management.Automation.PSCustomObject])] param () $path = Get-CopilotAtelierPath $moduleVersion = $null if ($ExecutionContext.SessionState.Module) { $moduleVersion = $ExecutionContext.SessionState.Module.Version } $deployedVersion = $null $deployedOn = $null if (Test-Path -LiteralPath $path.DeploymentManifestPath -PathType Leaf) { try { $deployment = Get-Content -LiteralPath $path.DeploymentManifestPath -Raw | ConvertFrom-Json $deployedVersion = $deployment.Version if ($deployment.InstalledOn) { # ConvertFrom-Json turns the ISO 8601 stamp into a local DateTime. $deployedOn = ([System.DateTime] $deployment.InstalledOn).ToUniversalTime() } } catch { Write-Warning -Message "Unable to read the deployment record at '$($path.DeploymentManifestPath)': $($_.Exception.Message)" } } $isCurrent = $null if ($moduleVersion -and $deployedVersion) { $isCurrent = [System.String] $moduleVersion -eq [System.String] $deployedVersion } return [PSCustomObject] @{ Version = $moduleVersion DeployedVersion = $deployedVersion DeployedOn = $deployedOn TargetPath = $path.TargetPath IsCurrent = $isCurrent } } #EndRegion './Public/Get-CopilotAtelierVersion.ps1' 80 #Region './Public/Install-CopilotAtelier.ps1' -1 function Install-CopilotAtelier { <# .SYNOPSIS Deploys the Copilot customizations and configures the VS Code client. .DESCRIPTION Copies the Agents, Instructions, Skills, Prompts, and Hooks directories to the canonical target, links the well-known ~/.copilot discovery folders to that target, merges the VS Code settings and keybindings, and records the deployed version. The canonical target is ~/OneDrive/CopilotAtelier when OneDrive is available and ~/CopilotAtelier otherwise. The command is idempotent: it removes obsolete location aliases without disturbing user-defined entries, tolerates comments in the VS Code configuration files, and creates a timestamped backup of every file it rewrites. Progress is written to the information stream. Run with -InformationAction Continue to see it. .PARAMETER ContentPath The directory that holds the customization directories. Defaults to the module base, or the repository root when the source files are dot-sourced during development. .PARAMETER SkipCopilotCliEnvironment Skips the user-scoped COPILOT_ALLOW_ALL configuration. Intended for sandboxed tests that must not mutate the host user profile. .PARAMETER IncludeClaudeCodeLinks Additionally links ~/.claude/skills and ~/.agents/skills to the Skills directory so Claude Code and other agentskills.io clients discover the same library. Off by default: VS Code reads all three user-level skill locations, so enabling this registers every Skill more than once in VS Code. Create-only, because an existing path belongs to that other tool. .OUTPUTS System.Management.Automation.PSCustomObject .EXAMPLE Install-CopilotAtelier -InformationAction Continue Deploys the customizations and prints each step. .EXAMPLE Install-CopilotAtelier -IncludeClaudeCodeLinks Also exposes the Skills directory to Claude Code. .LINK https://github.com/raandree/CopilotAtelier #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] [OutputType([System.Management.Automation.PSCustomObject])] param ( [Parameter()] [ValidateNotNullOrEmpty()] [System.String] $ContentPath, [Parameter()] [System.Management.Automation.SwitchParameter] $SkipCopilotCliEnvironment, [Parameter()] [System.Management.Automation.SwitchParameter] $IncludeClaudeCodeLinks ) $ErrorActionPreference = 'Stop' if (-not $PSBoundParameters.ContainsKey('ContentPath')) { $ContentPath = Get-CopilotAtelierContentPath } # The directories deployed to the canonical target, in discovery-link order. $customizationDirectory = [ordered] @{ Agents = 'agents' Instructions = 'instructions' Skills = 'skills' Prompts = 'prompts' Hooks = 'hooks' } $presentDirectory = @( $customizationDirectory.Keys | Where-Object -FilterScript { Test-Path -LiteralPath (Join-Path -Path $ContentPath -ChildPath $_) -PathType Container } ) if (-not $presentDirectory) { throw "No customization directory found under '$ContentPath'. Expected at least one of: $($customizationDirectory.Keys -join ', ')." } $path = Get-CopilotAtelierPath $timestamp = Get-Date -Format 'yyyyMMdd-HHmmss' if (-not (Test-Path -LiteralPath $path.SettingsDirectory)) { New-Item -ItemType Directory -Path $path.SettingsDirectory -Force | Out-Null Write-Information -MessageData "Created VS Code User directory: $($path.SettingsDirectory)" } if (-not (Test-Path -LiteralPath $path.SettingsPath)) { Write-Information -MessageData "VS Code settings file not found at $($path.SettingsPath) - creating a new one." '{}' | Set-Content -LiteralPath $path.SettingsPath -Encoding UTF8 $settingsText = '{}' } else { $settingsBackupPath = '{0}.{1}.bak' -f $path.SettingsPath, $timestamp Copy-Item -LiteralPath $path.SettingsPath -Destination $settingsBackupPath -Force Write-Information -MessageData "Backup created: $settingsBackupPath" $settingsText = Get-Content -LiteralPath $path.SettingsPath -Raw } $settings = ConvertFrom-Jsonc -Text $settingsText if ($null -eq $settings) { $settings = [PSCustomObject] @{} } if ($path.OneDriveRoot) { Write-Information -MessageData "OneDrive detected at: $($path.OneDriveRoot) - target tree will live there." } else { Write-Information -MessageData "OneDrive not found - target tree will live under $($path.TargetPath)." } <# Remove aliases written by releases that predate ~/.copilot discovery. Unrelated locations remain available to the user. #> $legacyLocationSetting = [ordered] @{ 'chat.agentFilesLocations' = 'Agents' 'chat.instructionsFilesLocations' = 'Instructions' 'chat.agentSkillsLocations' = 'Skills' 'chat.promptFilesLocations' = 'Prompts' } foreach ($location in $legacyLocationSetting.GetEnumerator()) { $removeLocationSettingEntry = @{ Settings = $settings PropertyName = $location.Key Entry = @( '~/{0}/{1}' -f $path.TargetName, $location.Value '~/OneDrive/{0}/{1}' -f $path.TargetName, $location.Value ) } Remove-LocationSettingEntry @removeLocationSettingEntry } <# `github.copilot.advanced` is the completions bag and has no documented `model` member, so earlier releases wrote a value Copilot never consumed. #> $settings.PSObject.Properties.Remove('github.copilot.advanced.model') $settingValue = [ordered] @{ 'chat.includeApplyingInstructions' = $true 'chat.includeReferencedInstructions' = $true 'gitlens.ai.vscode.model' = 'copilot:claude-opus-5' 'github.copilot.chat.agent.thinkingTool' = $true 'github.copilot.chat.search.semanticTextResults' = $true 'github.copilot.chat.skillTool.enabled' = $true 'github.copilot.chat.agent.maxRequests' = 500 } foreach ($setting in $settingValue.GetEnumerator()) { $settings | Add-Member -NotePropertyName $setting.Key -NotePropertyValue $setting.Value -Force } <# Unlike agents, instructions, skills, and hooks, the chat extension does not auto-discover ~/.copilot/prompts - it reads the per-profile prompts folder plus the paths listed in this setting. Merge so user-added prompt locations survive. #> Merge-LocationSetting -Settings $settings -PropertyName 'chat.promptFilesLocations' -NewEntry @{ '~/.copilot/prompts' = $true } <# ~/.copilot/hooks is a well-known user location, but naming it explicitly keeps discovery working if the implicit default changes. #> Merge-LocationSetting -Settings $settings -PropertyName 'chat.hookFilesLocations' -NewEntry @{ '~/.copilot/hooks' = $true } if ($PSCmdlet.ShouldProcess($path.SettingsPath, 'Write VS Code settings')) { $settings | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $path.SettingsPath -Encoding UTF8 } <# Only one location is populated: OneDrive when available, otherwise the user profile. A stale local copy from an earlier run is removed when OneDrive is now in use. #> if ($path.OneDriveRoot -and (Test-Path -LiteralPath $path.LegacyLocalPath)) { Remove-Item -LiteralPath $path.LegacyLocalPath -Recurse -Force Write-Information -MessageData "Removed legacy local copy: $($path.LegacyLocalPath)" } foreach ($directoryName in $customizationDirectory.Keys) { $destination = Join-Path -Path $path.TargetPath -ChildPath $directoryName if (Test-Path -LiteralPath $destination) { Remove-Item -LiteralPath $destination -Recurse -Force Write-Information -MessageData "Cleared: $destination" } New-Item -ItemType Directory -Path $destination -Force | Out-Null Write-Information -MessageData "Created: $destination" $source = Join-Path -Path $ContentPath -ChildPath $directoryName if (Test-Path -LiteralPath $source) { Copy-Item -Path (Join-Path -Path $source -ChildPath '*') -Destination $destination -Recurse -Force Write-Information -MessageData "Copied: $source -> $destination" } else { Write-Information -MessageData "Skipped: $source (not found in the module content)" } } $moduleVersion = $ExecutionContext.SessionState.Module.Version $deployedVersion = $null if ($moduleVersion) { $deployedVersion = $moduleVersion.ToString() } $deployment = [PSCustomObject] @{ Version = $deployedVersion InstalledOn = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') ContentPath = $ContentPath } if ($PSCmdlet.ShouldProcess($path.DeploymentManifestPath, 'Record the deployed version')) { $deployment | ConvertTo-Json -Depth 3 | Set-Content -LiteralPath $path.DeploymentManifestPath -Encoding UTF8 } foreach ($directoryName in $customizationDirectory.Keys) { $setCustomizationLink = @{ LinkPath = Join-Path -Path $path.CopilotRoot -ChildPath $customizationDirectory[$directoryName] TargetPath = Join-Path -Path $path.TargetPath -ChildPath $directoryName LinkItemType = $path.LinkItemType } Set-CustomizationLink @setCustomizationLink } <# Opt-in and create-only. VS Code reads ~/.copilot/skills, ~/.claude/skills, and ~/.agents/skills, so these links register every Skill more than once in VS Code. An existing path belongs to that other tool: adopting it would move the user's own skills into a tree the next run rebuilds. #> if ($IncludeClaudeCodeLinks) { Write-Information -MessageData 'Claude Code links requested: VS Code will register Skills from more than one location.' $claudeCodeLinkPath = @( Join-Path -Path (Join-Path -Path $path.UserHome -ChildPath '.claude') -ChildPath 'skills' Join-Path -Path (Join-Path -Path $path.UserHome -ChildPath '.agents') -ChildPath 'skills' ) foreach ($linkPath in $claudeCodeLinkPath) { $setCustomizationLink = @{ LinkPath = $linkPath TargetPath = Join-Path -Path $path.TargetPath -ChildPath 'Skills' LinkItemType = $path.LinkItemType CreateOnly = $true } Set-CustomizationLink @setCustomizationLink } } <# `gh copilot` consults COPILOT_ALLOW_ALL=1 to bypass the per-tool confirmation prompts that otherwise block non-interactive use of the custom agents and skills. Persisted at User scope so every new shell picks it up; the process variable is set too so the change is visible without opening a new shell. #> $copilotEnvironmentName = 'COPILOT_ALLOW_ALL' $copilotEnvironmentValue = '1' if ($SkipCopilotCliEnvironment) { Write-Verbose -Message "Skipped environment variable: $copilotEnvironmentName (requested)" } elseif ($PSCmdlet.ShouldProcess($copilotEnvironmentName, 'Set the Copilot CLI environment variable')) { $existingValue = [System.Environment]::GetEnvironmentVariable($copilotEnvironmentName, 'User') if ($existingValue -eq $copilotEnvironmentValue) { Write-Information -MessageData "Environment variable already set: $copilotEnvironmentName=$copilotEnvironmentValue (User)" } else { [System.Environment]::SetEnvironmentVariable($copilotEnvironmentName, $copilotEnvironmentValue, 'User') Write-Information -MessageData "Environment variable set: $copilotEnvironmentName=$copilotEnvironmentValue (User)" Write-Information -MessageData ' Open a new shell to pick up the change in other sessions.' } [System.Environment]::SetEnvironmentVariable($copilotEnvironmentName, $copilotEnvironmentValue, 'Process') } <# Idempotent keybinding merge: match on the (key, command, when) tuple so re-runs do not duplicate entries and user-added bindings are preserved. #> $keybindingSourcePath = Join-Path -Path (Join-Path -Path $ContentPath -ChildPath 'Keybindings') -ChildPath 'keybindings.json' if (Test-Path -LiteralPath $keybindingSourcePath) { if (-not (Test-Path -LiteralPath $path.KeybindingsPath)) { Write-Information -MessageData "VS Code keybindings file not found at $($path.KeybindingsPath) - creating a new one." '[]' | Set-Content -LiteralPath $path.KeybindingsPath -Encoding UTF8 $existingKeybindingText = '[]' } else { $keybindingBackupPath = '{0}.{1}.bak' -f $path.KeybindingsPath, $timestamp Copy-Item -LiteralPath $path.KeybindingsPath -Destination $keybindingBackupPath -Force Write-Information -MessageData "Backup created: $keybindingBackupPath" $existingKeybindingText = Get-Content -LiteralPath $path.KeybindingsPath -Raw } $existingKeybindingData = ConvertFrom-Jsonc -Text $existingKeybindingText $existingKeybinding = @() if ($null -ne $existingKeybindingData) { $existingKeybinding = @($existingKeybindingData) } $desiredKeybinding = @(ConvertFrom-Jsonc -Text (Get-Content -LiteralPath $keybindingSourcePath -Raw)) $desiredKey = @{} foreach ($binding in $desiredKeybinding) { $desiredKey[(Get-KeybindingKey -Binding $binding)] = $true } <# Keep every existing binding that is not one of ours, then append ours. Stale copies of our bindings are dropped, so an updated `when` clause does not leave a duplicate behind. #> $preservedKeybinding = @( $existingKeybinding | Where-Object -FilterScript { -not $desiredKey.ContainsKey((Get-KeybindingKey -Binding $_)) } ) if ($PSCmdlet.ShouldProcess($path.KeybindingsPath, 'Merge keybindings')) { @($preservedKeybinding) + @($desiredKeybinding) | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $path.KeybindingsPath -Encoding UTF8 } Write-Information -MessageData "Keybindings merged: $($desiredKeybinding.Count) bindings from the module, $($preservedKeybinding.Count) user bindings preserved." } else { Write-Information -MessageData "Skipped keybindings merge: $keybindingSourcePath not found." } Write-Information -MessageData '' Write-Information -MessageData "Settings updated at: $($path.SettingsPath)" Write-Information -MessageData 'Restart VS Code to apply changes.' return [PSCustomObject] @{ Version = $deployment.Version TargetPath = $path.TargetPath ContentPath = $ContentPath SettingsPath = $path.SettingsPath KeybindingsPath = $path.KeybindingsPath Deployed = $presentDirectory } } #EndRegion './Public/Install-CopilotAtelier.ps1' 435 #Region './Public/Update-CopilotAtelier.ps1' -1 function Update-CopilotAtelier { <# .SYNOPSIS Updates the module from a PowerShell repository and redeploys the customizations. .DESCRIPTION Compares the installed module version with the newest version published to the repository, installs the newer version when there is one, and then deploys its customization content with Install-CopilotAtelier so the ~/.copilot discovery folders match the module that is now installed. The command requires the module to be installed from a repository. When the commands were dot-sourced from a repository clone, update the clone with git and run Install-CopilotAtelier instead. .PARAMETER Repository The PowerShell repository to check. Defaults to PSGallery. .PARAMETER Force Redeploys even when the installed version is already the newest one. .PARAMETER SkipDeployment Installs the newer module version but does not deploy it. Use it to stage an update and deploy later with Install-CopilotAtelier. .PARAMETER IncludeClaudeCodeLinks Passed through to Install-CopilotAtelier so the redeployment keeps the Claude Code and agentskills.io links. .OUTPUTS System.Management.Automation.PSCustomObject .EXAMPLE Update-CopilotAtelier -InformationAction Continue Updates from the PowerShell Gallery and redeploys the customizations. .EXAMPLE Update-CopilotAtelier -Force Redeploys the current version even when no newer version exists. .LINK https://github.com/raandree/CopilotAtelier #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] [OutputType([System.Management.Automation.PSCustomObject])] param ( [Parameter()] [ValidateNotNullOrEmpty()] [System.String] $Repository = 'PSGallery', [Parameter()] [System.Management.Automation.SwitchParameter] $Force, [Parameter()] [System.Management.Automation.SwitchParameter] $SkipDeployment, [Parameter()] [System.Management.Automation.SwitchParameter] $IncludeClaudeCodeLinks ) $ErrorActionPreference = 'Stop' $module = $ExecutionContext.SessionState.Module if (-not $module) { throw 'Update-CopilotAtelier requires the module to be imported from an installed copy. Running from a repository clone? Update the clone with git and run Install-CopilotAtelier.' } $installedVersion = $module.Version try { $availableModule = Find-Module -Name $module.Name -Repository $Repository } catch { throw "Unable to query repository '$Repository' for '$($module.Name)': $($_.Exception.Message)" } $availableVersion = [System.Version] $availableModule.Version Write-Information -MessageData "Installed version: $installedVersion" Write-Information -MessageData "Available version: $availableVersion ($Repository)" $updated = $false if ($availableVersion -gt $installedVersion) { if ($PSCmdlet.ShouldProcess($module.Name, "Update to version $availableVersion from '$Repository'")) { try { Update-Module -Name $module.Name -RequiredVersion $availableVersion -Force } catch { throw "Unable to update '$($module.Name)' to $availableVersion. Install it with Install-Module so it can be updated in place. Reported error: $($_.Exception.Message)" } $updated = $true Write-Information -MessageData "Updated to version $availableVersion." } } else { Write-Information -MessageData 'Already at the newest published version.' } $deployment = $null if ($SkipDeployment) { Write-Information -MessageData 'Skipped deployment as requested. Run Install-CopilotAtelier to deploy.' } elseif ($updated -or $Force) { $targetVersion = $installedVersion if ($updated) { $targetVersion = $availableVersion } $updatedModule = Import-Module -Name $module.Name -RequiredVersion $targetVersion -Force -PassThru $installParameter = @{ IncludeClaudeCodeLinks = $IncludeClaudeCodeLinks } $deployment = & $updatedModule { param ( [System.Collections.Hashtable] $InstallParameter ) Install-CopilotAtelier @InstallParameter } $installParameter } else { Write-Information -MessageData 'Nothing to deploy. Use -Force to redeploy the current version.' } return [PSCustomObject] @{ Name = $module.Name PreviousVersion = $installedVersion Version = $availableVersion Updated = $updated Deployment = $deployment } } #EndRegion './Public/Update-CopilotAtelier.ps1' 164 |