src/PowerShell/Projects.ps1
|
# cdp PowerShell domain: Projects.ps1 # Loaded by src/cdp.psm1; do not dot-source peer files. function Add-Project { <# .SYNOPSIS Add the current directory to the project list. .DESCRIPTION Quickly adds the current working directory to your project configuration. If no name is provided, uses the directory name as the project name. .PARAMETER Name Optional custom name for the project. If not specified, uses the directory name. .PARAMETER Path Optional path to add. If not specified, uses the current directory. .PARAMETER ConfigPath Optional custom path to projects.json file. .EXAMPLE Add-Project # Adds current directory with folder name as project name .EXAMPLE Add-Project -Name "My Awesome Project" # Adds current directory with custom name .EXAMPLE Add-Project -Path "E:\Projects\MyApp" -Name "MyApp" # Adds specific path with custom name #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] param( [Parameter(Mandatory = $false)] [string]$Name, [Parameter(Mandatory = $false)] [string]$Path, [Parameter(Mandatory = $false)] [string]$ConfigPath, [Parameter(Mandatory = $false)] [switch]$PassThru ) # Determine path to add if ([string]::IsNullOrWhiteSpace($Path)) { $Path = (Get-Location).Path } # Resolve to absolute path $resolvedPath = Resolve-Path $Path -ErrorAction SilentlyContinue if (-not $resolvedPath) { Write-Host "Error: Invalid path." -ForegroundColor Red return } # Convert to string $Path = $resolvedPath.Path # Determine project name if ([string]::IsNullOrWhiteSpace($Name)) { $Name = Split-Path -Leaf $Path } # Get config path if ([string]::IsNullOrWhiteSpace($ConfigPath)) { $ConfigPath = Get-DefaultConfigPath } # Read existing projects try { $document = Read-CdpJsonArrayMutationDocument -LiteralPath $ConfigPath $projects = @($document.Value) # Check if project already exists $existingProject = Find-CdpProjectByLocalPath -Projects $projects -Path $Path if ($existingProject) { Write-Host "Project already exists: $($existingProject.name)" -ForegroundColor Yellow Write-Host "Path: $($existingProject.rootPath)" -ForegroundColor Gray return } # Add new project $newProject = [PSCustomObject]@{ name = $Name rootPath = $Path enabled = $true pinned = $false aliases = @() tags = @() paths = New-CdpProjectPathMap -RootPath $Path } $actionTarget = "$Name ($Path)" if (-not $PSCmdlet.ShouldProcess($actionTarget, "Add project to $ConfigPath")) { if ($PassThru) { $status = if ($WhatIfPreference) { 'preview' } else { 'canceled' } return New-CdpActionResult -Action 'add-project' -Target $actionTarget -Status $status -Changed $false -Details $newProject } return } $projects = @($projects) + $newProject # Save updated config [void](Write-CdpJsonFile -LiteralPath $ConfigPath -Value @($projects) -ExpectedFingerprint $document.Fingerprint) Write-Host "Project added successfully!" -ForegroundColor Green Write-Host " Name: $Name" -ForegroundColor Cyan Write-Host " Path: $Path" -ForegroundColor Gray Write-Host " Config: $ConfigPath" -ForegroundColor Gray if ($PassThru) { return New-CdpActionResult -Action 'add-project' -Target $actionTarget -Status 'succeeded' -Changed $true -Details $newProject } } catch { Write-Host "Error: Failed to add project." -ForegroundColor Red Write-Host "Details: $($_.Exception.Message)" -ForegroundColor Gray if ($PassThru) { return New-CdpActionResult -Action 'add-project' -Target ([string]$Name) -Status 'failed' -Changed $false -Error $_.Exception.Message } } } function Get-CdpUniqueName { param( [Parameter(Mandatory = $true)] [string]$Name, [Parameter(Mandatory = $true)] [AllowEmptyCollection()] [System.Collections.Generic.HashSet[string]]$UsedNames ) if ($UsedNames.Add($Name)) { return $Name } $index = 2 do { $candidate = "$Name-$index" $index++ } while (-not $UsedNames.Add($candidate)) $candidate } function Repair-ProjectConfig { <# .SYNOPSIS Safely repair the active cdp project configuration. .DESCRIPTION Cleans only the JSON configuration, never project files. Invalid entries are removed, duplicate root paths keep the first entry, duplicate names are renamed, missing paths are disabled, and missing pinned fields are filled with false. #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] param( [Parameter(Mandatory = $false, Position = 0)] [string]$ConfigPath, [Parameter(Mandatory = $false)] [switch]$PassThru ) if ([string]::IsNullOrWhiteSpace($ConfigPath)) { $ConfigPath = Get-DefaultConfigPath } try { $document = Read-CdpJsonArrayMutationDocument -LiteralPath $ConfigPath $projects = @($document.Value) $usedNames = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) $usedPaths = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) $summary = [ordered]@{ RemovedInvalid = 0; RemovedDuplicatePaths = 0; RenamedDuplicates = 0; DisabledMissingPaths = 0; AddedPinnedFields = 0; FixedEnabledFields = 0 } $unavailableExplicitPaths = 0 $cleanProjects = @() foreach ($project in $projects) { if ([string]::IsNullOrWhiteSpace($project.name) -or [string]::IsNullOrWhiteSpace($project.rootPath)) { $summary.RemovedInvalid++ continue } $pathKey = Get-CdpComparablePath -Path ([string]$project.rootPath) if (-not $usedPaths.Add($pathKey)) { $summary.RemovedDuplicatePaths++ continue } if (-not ($project.enabled -is [bool])) { if ($project.PSObject.Properties['enabled']) { $project.enabled = $false } else { $project | Add-Member -NotePropertyName enabled -NotePropertyValue $false } $summary.FixedEnabledFields++ } if (-not $project.PSObject.Properties['pinned']) { $project | Add-Member -NotePropertyName pinned -NotePropertyValue $false $summary.AddedPinnedFields++ } foreach ($propertyName in @('aliases', 'tags')) { if (-not $project.PSObject.Properties[$propertyName]) { $project | Add-Member -NotePropertyName $propertyName -NotePropertyValue @() } } $uniqueName = Get-CdpUniqueName -Name ([string]$project.name) -UsedNames $usedNames if (-not [string]::Equals($uniqueName, [string]$project.name, [StringComparison]::Ordinal)) { $project.name = $uniqueName $summary.RenamedDuplicates++ } $resolution = Resolve-CdpProjectPath -Project $project if ($resolution.ErrorCode) { throw "Project '$($project.name)' has an invalid $($resolution.Source): $($resolution.ErrorMessage)" } if ($project.enabled -eq $true -and -not (Test-Path -LiteralPath $resolution.ResolvedPath)) { if ($resolution.IsExplicit) { $unavailableExplicitPaths++ } else { $project.enabled = $false $summary.DisabledMissingPaths++ } } $cleanProjects += $project } $changeCount = 0 foreach ($value in $summary.Values) { $changeCount += [int]$value } $details = [PSCustomObject]@{ ConfigPath = $ConfigPath ProjectCount = $cleanProjects.Count RemovedInvalid = $summary.RemovedInvalid RemovedDuplicatePaths = $summary.RemovedDuplicatePaths RenamedDuplicates = $summary.RenamedDuplicates DisabledMissingPaths = $summary.DisabledMissingPaths AddedPinnedFields = $summary.AddedPinnedFields FixedEnabledFields = $summary.FixedEnabledFields UnavailableExplicitPaths = $unavailableExplicitPaths } if ($changeCount -eq 0) { Write-Host "No project configuration repairs are needed." -ForegroundColor Green if ($unavailableExplicitPaths -gt 0) { Write-Host " Kept $unavailableExplicitPaths unavailable explicit profile paths unchanged." -ForegroundColor Yellow } if ($PassThru) { return New-CdpActionResult -Action 'repair-config' -Target $ConfigPath -Status 'skipped' -Changed $false -Details $details } return } if (-not $PSCmdlet.ShouldProcess($ConfigPath, "Apply $changeCount project configuration repairs")) { if ($PassThru) { $status = if ($WhatIfPreference) { 'preview' } else { 'canceled' } return New-CdpActionResult -Action 'repair-config' -Target $ConfigPath -Status $status -Changed $false -Details $details } return } [void](Write-CdpJsonFile -LiteralPath $ConfigPath -Value @($cleanProjects) -ExpectedFingerprint $document.Fingerprint) Write-Host "cdp config repaired: $ConfigPath" -ForegroundColor Green foreach ($item in $summary.GetEnumerator()) { Write-Host " $($item.Key): $($item.Value)" -ForegroundColor Gray } if ($unavailableExplicitPaths -gt 0) { Write-Host " UnavailableExplicitPaths: $unavailableExplicitPaths (kept unchanged)" -ForegroundColor Yellow } if ($PassThru) { return New-CdpActionResult -Action 'repair-config' -Target $ConfigPath -Status 'succeeded' -Changed $true -Details $details } } catch { Write-Host "Error: Failed to repair project configuration." -ForegroundColor Red Write-Host "Details: $($_.Exception.Message)" -ForegroundColor Gray if ($PassThru) { return New-CdpActionResult -Action 'repair-config' -Target $ConfigPath -Status 'failed' -Changed $false -Error $_.Exception.Message } } } function Remove-Project { <# .SYNOPSIS Remove a project from the configuration. .DESCRIPTION Removes a project by name from your project configuration. .PARAMETER Name Name of the project to remove. Supports fuzzy matching. .PARAMETER ConfigPath Optional custom path to projects.json file. .EXAMPLE Remove-Project -Name "MyProject" # Removes project named "MyProject" .EXAMPLE Remove-Project # Opens interactive fzf menu to select project to remove #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] param( [Parameter(Mandatory = $false)] [string]$Name, [Parameter(Mandatory = $false)] [string]$ConfigPath, [Parameter(Mandatory = $false)] [switch]$PassThru ) # Get config path if ([string]::IsNullOrWhiteSpace($ConfigPath)) { $ConfigPath = Get-DefaultConfigPath } if (-not (Test-Path $ConfigPath)) { Write-Host "Error: Configuration file not found at: $ConfigPath" -ForegroundColor Red return } try { $document = Read-CdpJsonDocument -LiteralPath $ConfigPath $projects = $document.Value if ($projects.Count -eq 0) { Write-Host "No projects found in configuration." -ForegroundColor Yellow return } # If no name provided, use fzf to select if ([string]::IsNullOrWhiteSpace($Name)) { $fzfCommand = Resolve-CdpFzfCommand if (-not $fzfCommand) { Write-Host "Error: Please provide project name or install fzf for interactive selection." -ForegroundColor Red return } $originalOutputEncoding = [Console]::OutputEncoding $originalInputEncoding = [Console]::InputEncoding try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 [Console]::InputEncoding = [System.Text.Encoding]::UTF8 $Name = $projects.name | & $fzfCommand ` --prompt="Select project to remove: " ` --height=60% ` --layout=reverse ` --border ` --no-mouse } finally { [Console]::OutputEncoding = $originalOutputEncoding [Console]::InputEncoding = $originalInputEncoding } if ([string]::IsNullOrWhiteSpace($Name)) { return } } # Find and remove project $projectToRemove = $projects | Where-Object { $_.name -eq $Name } if (-not $projectToRemove) { Write-Host "Error: Project not found: $Name" -ForegroundColor Red return } # Show the plan; ShouldProcess owns confirmation and WhatIf behavior. Write-Host "`nProject scheduled for removal:" -ForegroundColor Yellow Write-Host " Name: $($projectToRemove.name)" -ForegroundColor Cyan Write-Host " Path: $($projectToRemove.rootPath)" -ForegroundColor Gray if (-not $PSCmdlet.ShouldProcess([string]$projectToRemove.name, "Remove project from $ConfigPath")) { if ($PassThru) { $status = if ($WhatIfPreference) { 'preview' } else { 'canceled' } return New-CdpActionResult -Action 'remove-project' -Target ([string]$projectToRemove.name) -Status $status -Changed $false -Details $projectToRemove } return } # Remove project $updatedProjects = $projects | Where-Object { $_.name -ne $Name } [void](Write-CdpJsonFile -LiteralPath $ConfigPath -Value @($updatedProjects) -ExpectedFingerprint $document.Fingerprint) Write-Host "`nProject removed successfully: $Name" -ForegroundColor Green if ($PassThru) { return New-CdpActionResult -Action 'remove-project' -Target ([string]$Name) -Status 'succeeded' -Changed $true -Details $projectToRemove } } catch { Write-Host "Error: Failed to remove project." -ForegroundColor Red Write-Host "Details: $($_.Exception.Message)" -ForegroundColor Gray if ($PassThru) { return New-CdpActionResult -Action 'remove-project' -Target ([string]$Name) -Status 'failed' -Changed $false -Error $_.Exception.Message } } } function Edit-ProjectConfig { <# .SYNOPSIS Open the project configuration file in your default editor. .DESCRIPTION Quickly opens the projects.json file for manual editing. .PARAMETER ConfigPath Optional custom path to projects.json file. .EXAMPLE Edit-ProjectConfig # Opens config file in default editor #> [CmdletBinding()] param( [Parameter(Mandatory = $false)] [string]$ConfigPath ) # Get config path if ([string]::IsNullOrWhiteSpace($ConfigPath)) { $ConfigPath = Get-DefaultConfigPath } # Initialize config file if needed Initialize-ConfigFile -ConfigPath $ConfigPath Write-Host "Opening config file: $ConfigPath" -ForegroundColor Cyan # Try to open with VS Code/Cursor first, then default editor if (Get-Command code -ErrorAction SilentlyContinue) { code $ConfigPath } elseif (Get-Command cursor -ErrorAction SilentlyContinue) { cursor $ConfigPath } else { Start-Process $ConfigPath } } function Set-ProjectConfig { <# .SYNOPSIS Change the active configuration file. .DESCRIPTION Allows you to switch between different project configuration files (Cursor, VS Code, custom config). Your choice will be saved and used for all future cdp commands. .EXAMPLE Set-ProjectConfig # Opens interactive menu to select a different config file .EXAMPLE cdp-config # Using the alias #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] param( [Parameter(Mandatory = $false, Position = 0)] [int]$Selection = 0, [Parameter(Mandatory = $false)] [switch]$PassThru ) Write-Host "`n========================================" -ForegroundColor Cyan Write-Host " Change Configuration File" -ForegroundColor Cyan Write-Host "========================================`n" -ForegroundColor Cyan # Find all available configs $availableConfigs = Get-AllAvailableConfigs if ($availableConfigs.Count -eq 0) { Write-Host "No configuration files found." -ForegroundColor Yellow Write-Host "" Write-Host "Available options:" -ForegroundColor Cyan Write-Host " 1. Create a custom config with: " -NoNewline -ForegroundColor Gray Write-Host "cdp-add" -ForegroundColor Cyan Write-Host " 2. Install Project Manager extension in VS Code/Cursor" -ForegroundColor Gray return } # Show current config $currentConfig = Get-StoredConfigChoice if ($currentConfig) { Write-Host "Current configuration:" -ForegroundColor Cyan $currentSource = ($availableConfigs | Where-Object { $_.Path -eq $currentConfig }).Source if ($currentSource) { Write-Host " $currentSource" -ForegroundColor Green } Write-Host " $currentConfig" -ForegroundColor Gray Write-Host "" } # Show all available configs Write-Host "Available configuration files:" -ForegroundColor Cyan Write-Host ("=" * 80) -ForegroundColor Gray Write-Host "" for ($i = 0; $i -lt $availableConfigs.Count; $i++) { $config = $availableConfigs[$i] $isCurrent = ($config.Path -eq $currentConfig) Write-Host " [$($i + 1)] " -ForegroundColor Yellow -NoNewline Write-Host "$($config.Source)" -ForegroundColor Green -NoNewline if ($isCurrent) { Write-Host " (current)" -ForegroundColor Cyan } else { Write-Host "" } Write-Host " $($config.Path)" -ForegroundColor Gray } Write-Host "" # Resolve the explicit selection, retaining the interactive legacy path. if ($Selection -le 0) { $selectionText = Read-Host "Select config file (1-$($availableConfigs.Count), or 0 to cancel)" $parsedSelection = 0 if (-not [int]::TryParse($selectionText, [ref]$parsedSelection) -or $parsedSelection -eq 0) { Write-Host "`nOperation cancelled." -ForegroundColor Gray return } $Selection = $parsedSelection } if ($Selection -lt 1 -or $Selection -gt $availableConfigs.Count) { throw "Invalid selection. Please choose a number between 1 and $($availableConfigs.Count)." } $selectedPath = $availableConfigs[$Selection - 1].Path $selectedSource = $availableConfigs[$Selection - 1].Source if (-not $PSCmdlet.ShouldProcess($selectedPath, 'Persist active cdp configuration choice')) { if ($PassThru) { $status = if ($WhatIfPreference) { 'preview' } else { 'canceled' } return New-CdpActionResult -Action 'select-config' -Target $selectedPath -Status $status -Changed $false } return } Save-ConfigChoice -ConfigPath $selectedPath Write-Host "`n========================================" -ForegroundColor Green Write-Host " Configuration Updated!" -ForegroundColor Green Write-Host "========================================`n" -ForegroundColor Green Write-Host "Now using: " -NoNewline -ForegroundColor Gray Write-Host "$selectedSource" -ForegroundColor Green Write-Host "Path: " -NoNewline -ForegroundColor Gray Write-Host "$selectedPath" -ForegroundColor Cyan Write-Host "Saved to: " -NoNewline -ForegroundColor Gray Write-Host "~/.cdp/config" -ForegroundColor Cyan Write-Host "" if ($PassThru) { return New-CdpActionResult -Action 'select-config' -Target $selectedPath -Status 'succeeded' -Changed $true } } |