src/PowerShell/Commands.ps1

# cdp PowerShell domain: Commands.ps1
# Loaded by src/cdp.psm1; do not dot-source peer files.

function Switch-Project {
    <#
    .SYNOPSIS
        Switch to a project directory using fzf fuzzy finder.
 
    .DESCRIPTION
        Provides an interactive terminal menu powered by fzf to quickly navigate
        between enabled projects from Project Manager configuration. Automatically
        updates the Windows Terminal tab title to match the selected project name.
 
    .PARAMETER ConfigPath
        Optional custom path to projects.json file. If not specified, uses the
        default Cursor/VS Code Project Manager location.
 
    .PARAMETER Query
        Optional project name or path query. If exactly one enabled project
        matches, switches directly. If multiple projects match, opens fzf with
        only those matches.
 
    .PARAMETER WSL
        If specified, launches WSL and changes to the project directory within WSL.
        Windows paths are automatically converted to WSL mount points (/mnt/c/, etc.).
 
    .PARAMETER Open
        Optional command to start after switching to the selected project. Common
        values include code, cursor, codex, claude, and gemini.
 
    .PARAMETER AllowHook
        Execute a project command hook for this switch only. Command hooks are
        skipped by default.
 
    .PARAMETER NoHook
        Skip all onEnter environment values and command hooks for this switch.
 
    .EXAMPLE
        Switch-Project
        # Opens fzf menu to select from enabled projects
 
    .EXAMPLE
        cdp
        # Using the default alias
 
    .EXAMPLE
        cdp api
        # Directly switches to the matching project, or filters fzf to matches
 
    .EXAMPLE
        cdp -WSL
        # Select a project and launch WSL in that directory
 
    .EXAMPLE
        cdp api -Open codex
        # Switches to the matching project and starts Codex there
 
    .NOTES
        Requires fzf to be installed. Install via: winget install fzf
    #>


    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $false)]
        [string]$ConfigPath,

        [Parameter(Mandatory = $false)]
        [string]$Query,

        [Parameter(Mandatory = $false)]
        [switch]$WSL,

        [Parameter(Mandatory = $false)]
        [Alias('o')]
        [string]$Open,

        [Parameter(Mandatory = $false)]
        [switch]$AllowHook,

        [Parameter(Mandatory = $false)]
        [switch]$NoHook
    )

    # Get config path
    if ([string]::IsNullOrWhiteSpace($ConfigPath)) {
        $ConfigPath = Get-DefaultConfigPath
    }

    # Initialize config file if it doesn't exist and not using Project Manager
    $customConfigPath = Join-Path (Get-CdpUserHome) ".cdp\projects.json"
    if ($ConfigPath -eq $customConfigPath) {
        Initialize-ConfigFile -ConfigPath $ConfigPath
    }

    if (-not (Test-Path $ConfigPath)) {
        Write-Host "Error: Configuration file not found at: $ConfigPath" -ForegroundColor Red
        return
    }

    try {
        $configData = Get-CdpProjectConfig -ConfigPath $ConfigPath
        $enabledProjects = @(Sort-CdpProjectsForDisplay -Projects @($configData.EnabledProjects))
    } catch {
        Write-Host "Error: Failed to read or parse configuration file." -ForegroundColor Red
        Write-Host "Details: $($_.Exception.Message)" -ForegroundColor Gray
        return
    }

    if ($null -eq $enabledProjects -or $enabledProjects.Count -eq 0) {
        Write-Host "No enabled projects found in configuration." -ForegroundColor Yellow
        return
    }

    $projectsForSelection = $enabledProjects
    $selectedProjectName = $null

    if (-not [string]::IsNullOrWhiteSpace($Query)) {
        $queryMatches = @(Get-CdpProjectMatches -Projects $enabledProjects -Query $Query)

        if ($queryMatches.Count -eq 0) {
            Write-Host "No project matched query: $Query" -ForegroundColor Yellow
            return
        }

        if ($queryMatches.Count -eq 1) {
            $selectedProjectName = $queryMatches[0].name
        } else {
            $projectsForSelection = $queryMatches
        }
    }

    if ([string]::IsNullOrWhiteSpace($selectedProjectName)) {
        # Check if fzf is installed only when interactive selection is needed.
        $fzfCommand = Resolve-CdpFzfCommand
        if (-not $fzfCommand) {
            Write-Host "Error: 'fzf' command not found." -ForegroundColor Red
            Write-Host "Please install fzf first: winget install fzf" -ForegroundColor Cyan
            Write-Host "Then restart your terminal." -ForegroundColor Cyan
            return
        }

        # Set console encoding for fzf interaction
        # CRITICAL: Must set BOTH InputEncoding and OutputEncoding for IME to work
        $originalOutputEncoding = [Console]::OutputEncoding
        $originalInputEncoding = [Console]::InputEncoding
        $previewDir = $null
        try {
            [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
            [Console]::InputEncoding = [System.Text.Encoding]::UTF8

            # Launch fzf with themed ANSI rows and a lightweight project preview.
            # Note: --no-mouse prevents IME mouse click conflicts
            $prompt = if ([string]::IsNullOrWhiteSpace($Query)) {
                "cdp > "
            } else {
                "cdp ($Query) > "
            }
            $header = Get-CdpPickerHeader `
                -ShownProjectCount $projectsForSelection.Count `
                -TotalProjectCount $enabledProjects.Count `
                -ConfigPath $ConfigPath
            $pathProfile = if ($WSL) { 'wsl' } else { $null }
            $previewDir = New-CdpPickerPreviewDirectory -Projects $projectsForSelection -Profile $pathProfile
            $previewCommand = Get-CdpPickerPreviewCommand -PreviewDir $previewDir
            $colorOption = "--color=$(Get-CdpFzfColorTheme)"
            $pickerLines = for ($i = 0; $i -lt $projectsForSelection.Count; $i++) {
                New-CdpPickerLine -Project $projectsForSelection[$i] -Index ($i + 1) -Profile $pathProfile
            }

            $selectedLine = $pickerLines | & $fzfCommand `
                --prompt=$prompt `
                --header=$header `
                --height=70% `
                --layout=reverse `
                --border=rounded `
                --border-label=" cdp warp " `
                --ansi `
                --delimiter="$([char]9)" `
                --with-nth=4,5,6 `
                --nth=2,3 `
                --no-mouse `
                --preview=$previewCommand `
                --preview-window=right:50%:wrap `
                --pointer=">" `
                --marker="*" `
                $colorOption

            if (-not [string]::IsNullOrWhiteSpace($selectedLine)) {
                $selectedFields = $selectedLine -split "`t"
                if ($selectedFields.Count -ge 2) {
                    $selectedProjectName = $selectedFields[1]
                }
            }
        }
        finally {
            [Console]::OutputEncoding = $originalOutputEncoding
            [Console]::InputEncoding = $originalInputEncoding
            if (-not [string]::IsNullOrWhiteSpace($previewDir) -and (Test-Path -LiteralPath $previewDir)) {
                Remove-Item -LiteralPath $previewDir -Recurse -Force -ErrorAction SilentlyContinue
            }
        }
    }

    # Process selection
    # Note: Don't check exit code to avoid IME-related false cancellations
    # Only check if a project was actually selected
    if ([string]::IsNullOrWhiteSpace($selectedProjectName)) {
        # User cancelled or no selection made
        return
    }

    $selectedProject = @($enabledProjects | Where-Object {
        $_.name -eq $selectedProjectName
    }) | Select-Object -First 1

    if ($null -eq $selectedProject) {
        Write-Host "Error: Could not find project '$selectedProjectName'." -ForegroundColor Red
        return
    }

    try {
        $resolution = Resolve-CdpProjectPath -Project $selectedProject -Profile $(if ($WSL) { 'wsl' } else { $null })
    } catch {
        Write-Host "Error: $($_.Exception.Message)" -ForegroundColor Red
        return
    }
    if ($resolution.ErrorCode) {
        Write-Host "Error: $($resolution.ErrorMessage)" -ForegroundColor Red
        Write-Host "Project: $selectedProjectName; profile: $($resolution.Profile)" -ForegroundColor Gray
        return
    }

    if ($WSL -or (Test-Path -LiteralPath $resolution.ResolvedPath)) {
        if ($WSL) {
            $wslPath = $resolution.ResolvedPath
            $launchText = if ([string]::IsNullOrWhiteSpace($Open)) {
                "Launching WSL in project: $($selectedProject.name)"
            } else {
                "Launching WSL workspace: $($selectedProject.name)"
            }
            Write-Host $launchText -ForegroundColor Green
            Write-Host "WSL path: $wslPath" -ForegroundColor Gray

            if ([string]::IsNullOrWhiteSpace($Open)) {
                wsl --cd $wslPath
            } else {
                Invoke-CdpWorkspaceLauncher -Project $selectedProject -Open $Open -WSL
            }
            if ($LASTEXITCODE -eq 0 -or $env:CDP_OPEN_DRY_RUN -eq '1') {
                Add-CdpRecentProject -Project $selectedProject
            }
        } else {
            Set-Location -LiteralPath $resolution.ResolvedPath
            Add-CdpRecentProject -Project $selectedProject
            Write-Host "Switched to project: $($selectedProject.name)" -ForegroundColor Green

            # Update Windows Terminal tab title
            $newTitle = $selectedProject.name
            Write-Host -NoNewline "$([char]27)]0;$newTitle$([char]7)"

            Invoke-CdpOnEnter `
                -Project $selectedProject `
                -ConfigPath $ConfigPath `
                -AllowHook:$AllowHook `
                -NoHook:$NoHook

            if (-not [string]::IsNullOrWhiteSpace($Open)) {
                Invoke-CdpWorkspaceLauncher -Project $selectedProject -Open $Open
            }
        }
    } else {
        Write-Host "Error: Directory not found for project '$selectedProjectName'." -ForegroundColor Red
        Write-Host "Profile: $($resolution.Profile); path: $($resolution.ResolvedPath)" -ForegroundColor Gray
    }
}

function Get-ProjectList {
    <#
    .SYNOPSIS
        List all enabled projects from Project Manager.
 
    .DESCRIPTION
        Displays all enabled projects with their names and paths.
 
    .PARAMETER ConfigPath
        Optional custom path to projects.json file.
 
    .EXAMPLE
        Get-ProjectList
        # Lists all enabled projects
    #>


    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $false)]
        [string]$ConfigPath
    )

    # Get config path
    if ([string]::IsNullOrWhiteSpace($ConfigPath)) {
        $ConfigPath = Get-DefaultConfigPath
    }

    # Initialize config file if it doesn't exist and not using Project Manager
    $customConfigPath = Join-Path (Get-CdpUserHome) ".cdp\projects.json"
    if ($ConfigPath -eq $customConfigPath) {
        Initialize-ConfigFile -ConfigPath $ConfigPath
    }

    if (-not (Test-Path $ConfigPath)) {
        Write-Host "Error: Configuration file not found at: $ConfigPath" -ForegroundColor Red
        return
    }

    try {
        $configData = Get-CdpProjectConfig -ConfigPath $ConfigPath
        $enabledProjects = @(Sort-CdpProjectsForDisplay -Projects @($configData.EnabledProjects))

        if ($null -eq $enabledProjects -or $enabledProjects.Count -eq 0) {
            Write-Host "No enabled projects found." -ForegroundColor Yellow
            return
        }

        $nameWidth = 14
        foreach ($project in $enabledProjects) {
            $projectName = [string]$project.name
            $nameWidth = [Math]::Max($nameWidth, $projectName.Length)
        }
        $nameWidth = [Math]::Min($nameWidth, 30)

        Write-Host "`ncdp projects " -ForegroundColor Cyan -NoNewline
        Write-Host "($($enabledProjects.Count) enabled)" -ForegroundColor DarkGray
        Write-Host ("-" * 104) -ForegroundColor DarkGray
        Write-Host (" {0,-4} " -f "#") -ForegroundColor DarkGray -NoNewline
        Write-Host ("{0,-5} " -f "Pin") -ForegroundColor DarkGray -NoNewline
        Write-Host (("{0,-$nameWidth} " -f "Project")) -ForegroundColor Cyan -NoNewline
        Write-Host "Path" -ForegroundColor DarkGray
        Write-Host ("-" * 104) -ForegroundColor DarkGray

        $index = 1
        foreach ($project in $enabledProjects) {
            $number = "{0:00}" -f $index
            $projectName = Limit-CdpText -Text ([string]$project.name) -MaxLength $nameWidth
            $pinText = if (Test-CdpProjectPinned -Project $project) { "*" } else { "" }
            Write-Host (" {0,-4} " -f $number) -ForegroundColor DarkGray -NoNewline
            Write-Host ("{0,-5} " -f $pinText) -ForegroundColor Yellow -NoNewline
            Write-Host (("{0,-$nameWidth} " -f $projectName)) -ForegroundColor Green -NoNewline
            $resolution = Resolve-CdpProjectPath -Project $project
            $pathText = if ($resolution.ErrorCode) { "<invalid $($resolution.Source)>" } else { $resolution.ResolvedPath }
            Write-Host $pathText -ForegroundColor DarkGray
            $index++
        }

        Write-Host ("-" * 104) -ForegroundColor DarkGray
        Write-Host "config: $ConfigPath" -ForegroundColor DarkGray
    } catch {
        Write-Host "Error: Failed to read configuration." -ForegroundColor Red
        Write-Host "Details: $($_.Exception.Message)" -ForegroundColor Gray
    }
}

function Invoke-CdpManagementInvocation {
    param([object]$Invocation)

    $safety = @{}
    if ($Invocation.DryRun) { $safety.WhatIf = $true }
    if ($Invocation.Yes) { $safety.Confirm = $false }

    switch ($Invocation.Kind) {
        'hook' { Invoke-CdpHookCommand @safety -Action $Invocation.HookAction -Name $Invocation.Name -ConfigPath $Invocation.ConfigPath }
        'doctor' {
            if ($Invocation.Fix) { Repair-ProjectConfig @safety -ConfigPath $Invocation.ConfigPath }
            else { Test-ProjectHealth -ConfigPath $Invocation.ConfigPath }
        }
        'about' { Show-CdpAbout -ConfigPath $Invocation.ConfigPath }
        'recent' {
            if ($Invocation.RecentAction -eq 'reset') { Reset-CdpRecentProjects @safety }
            else { Get-CdpRecentProjects -Count $Invocation.Count }
        }
        'config' { Set-ProjectConfig @safety -Selection $Invocation.Count }
        'add' { Add-Project @safety -Name $Invocation.Name -Path $Invocation.RootPath -ConfigPath $Invocation.ConfigPath }
        'remove' { Remove-Project @safety -Name $Invocation.Name -ConfigPath $Invocation.ConfigPath }
        'pin' { Set-ProjectPin @safety -Name $Invocation.Name -ConfigPath $Invocation.ConfigPath }
        'unpin' { Clear-ProjectPin @safety -Name $Invocation.Name -ConfigPath $Invocation.ConfigPath }
        'alias' { Add-ProjectAlias @safety -Name $Invocation.Name -Alias $Invocation.Value -ConfigPath $Invocation.ConfigPath }
        'unalias' { Remove-ProjectAlias @safety -Name $Invocation.Name -Alias $Invocation.Value -ConfigPath $Invocation.ConfigPath }
        'tag' { Add-ProjectTag @safety -Name $Invocation.Name -Tag $Invocation.Value -ConfigPath $Invocation.ConfigPath }
        'untag' { Remove-ProjectTag @safety -Name $Invocation.Name -Tag $Invocation.Value -ConfigPath $Invocation.ConfigPath }
        'clean' { Repair-ProjectConfig @safety -ConfigPath $Invocation.ConfigPath }
        'init' {
            Initialize-Cdp @safety -RootPath $Invocation.RootPath -ConfigPath $Invocation.ConfigPath -MaxDepth $Invocation.MaxDepth
        }
        'scan' {
            Import-GitProjects @safety -RootPath $Invocation.RootPath -ConfigPath $Invocation.ConfigPath -MaxDepth $Invocation.MaxDepth
        }
    }
}

function Test-CdpInvocationMutation {
    param([Parameter(Mandatory = $true)][object]$Invocation)

    if ($Invocation.Kind -eq 'exec') { return $true }
    if ($Invocation.Kind -eq 'status') { return $Invocation.Fix -or $Invocation.Push }
    if ($Invocation.Kind -eq 'workspace') {
        return $Invocation.WorkspaceAction -in @('add', 'edit', 'remove', 'open') -or
            ($Invocation.WorkspaceAction -eq 'validate' -and $Invocation.Fix)
    }
    if ($Invocation.Kind -eq 'hook') { return $Invocation.HookAction -in @('trust', 'revoke') }
    if ($Invocation.Kind -eq 'recent') { return $Invocation.RecentAction -eq 'reset' }
    if ($Invocation.Kind -eq 'doctor') { return $Invocation.Fix }
    $Invocation.Kind -in @('add', 'remove', 'pin', 'unpin', 'alias', 'unalias', 'tag', 'untag', 'clean', 'init', 'scan', 'config')
}

function Invoke-Cdp {
    <#
    .SYNOPSIS
        Short command entry point for cdp.
 
    .DESCRIPTION
        Keeps the classic `cdp` project switch behavior and adds lightweight
        subcommands such as `cdp doctor`, `cdp workspace`, and `cdp exec`.
 
    .PARAMETER Command
        Optional subcommand, query, or path-like config argument. Use `doctor` to
        run diagnostics. Non-path values are treated as project queries.
 
    .PARAMETER ConfigPath
        Optional custom path to projects.json file.
 
    .PARAMETER Query
        Optional project name or path query. `cdp api` is shorthand for
        `Invoke-Cdp -Query api`.
 
    .PARAMETER WSL
        If specified, launches WSL and changes to the selected project directory.
 
    .PARAMETER Open
        Optional command to start after switching to the selected project.
 
    .PARAMETER AllowHook
        Execute a project command hook for this switch only. Command hooks are
        skipped by default.
 
    .PARAMETER NoHook
        Skip all onEnter behavior for this switch.
 
    .EXAMPLE
        cdp
        # Opens fzf menu to select a project
 
    .EXAMPLE
        cdp doctor
        # Runs cdp diagnostics
 
    .EXAMPLE
        cdp api
        # Switches directly when the query has one match
 
    .EXAMPLE
        cdp api -Open codex
        # Switches to the matching project and starts Codex there
 
    .EXAMPLE
        cdp exec '@work' --dry-run -- git status --short
        # Previews one native command across every enabled project tagged work
    #>


    [CmdletBinding(SupportsShouldProcess = $true)]
    param(
        [Parameter(Mandatory = $false, Position = 0)]
        [string]$Command,

        [Parameter(Mandatory = $false, Position = 1)]
        [string]$ConfigPath,

        [Parameter(Mandatory = $false)]
        [string]$Query,

        [Parameter(Mandatory = $false)]
        [switch]$WSL,

        [Parameter(Mandatory = $false)]
        [Alias('o')]
        [string]$Open,

        [Parameter(Mandatory = $false)]
        [switch]$AllowHook,

        [Parameter(Mandatory = $false)]
        [switch]$NoHook,

        [Parameter(Mandatory = $false, ValueFromRemainingArguments = $true)]
        [string[]]$RemainingArgs
    )

    $jsonRequested = Test-CdpJsonRequested -Command $Command -ConfigPath $ConfigPath -RemainingArgs $RemainingArgs
    $execRequested = (Resolve-CdpCommandKind -Command $Command) -eq 'exec'
    try {
        $parserArgs = @($RemainingArgs)
        if ($AllowHook) { $parserArgs = @('--allow-hook') + $parserArgs }
        if ($NoHook) { $parserArgs = @('--no-hook') + $parserArgs }
        $invocation = ConvertFrom-CdpInvokeArguments `
            -Command $Command `
            -ConfigPath $ConfigPath `
            -Query $Query `
            -Open $Open `
            -RemainingArgs $parserArgs
        if ($WhatIfPreference) { $invocation.DryRun = $true }
        if ($PSBoundParameters.ContainsKey('Confirm') -and $PSBoundParameters['Confirm'] -eq $false) {
            $invocation.Yes = $true
        }
        if ($invocation.DryRun -and $invocation.Yes) {
            throw 'The -WhatIf and explicit confirmation options cannot be combined.'
        }
        if (($invocation.DryRun -or $invocation.Yes) -and -not (Test-CdpInvocationMutation -Invocation $invocation)) {
            throw 'Safety options are only valid for mutating commands.'
        }
    } catch {
        if ($jsonRequested) {
            [Console]::Error.WriteLine("Error: $($_.Exception.Message)")
            $global:LASTEXITCODE = 3
        } else {
            Write-Host "Error: $($_.Exception.Message)" -ForegroundColor Red
            if ($execRequested) { $global:LASTEXITCODE = 3 }
        }
        return
    }

    switch ($invocation.Kind) {
        'exec' { Invoke-CdpExecInvocation -Invocation $invocation; return }
        'status' { Invoke-CdpStatusInvocation -Invocation $invocation; return }
        'workspace' { Invoke-CdpWorkspaceInvocation -Invocation $invocation; return }
        'switch' {
            Switch-Project `
                -ConfigPath $invocation.ConfigPath `
                -Query $invocation.Query `
                -WSL:$WSL `
                -Open $invocation.Open `
                -AllowHook:$invocation.AllowHook `
                -NoHook:$invocation.NoHook
            return
        }
        default { Invoke-CdpManagementInvocation -Invocation $invocation }
    }
}