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