private/Invoke-WtwCursorProject.ps1
|
function Get-WtwCursorDataHome { [CmdletBinding()] param() if ($env:CURSOR_USER_DATA_DIR) { return [System.IO.Path]::GetFullPath($env:CURSOR_USER_DATA_DIR) } if ($IsMacOS) { return [System.IO.Path]::GetFullPath((Join-Path $HOME 'Library/Application Support/Cursor')) } if ($IsWindows) { return [System.IO.Path]::GetFullPath((Join-Path $env:APPDATA 'Cursor')) } return [System.IO.Path]::GetFullPath((Join-Path $HOME '.config/Cursor')) } function Get-WtwCursorGlobalStatePath { [CmdletBinding()] param([string] $DataHome = (Get-WtwCursorDataHome)) return [System.IO.Path]::GetFullPath((Join-Path $DataHome 'User/globalStorage/state.vscdb')) } function Test-WtwCursorPresent { [CmdletBinding()] param([string] $DataHome = (Get-WtwCursorDataHome)) if (Get-Command cursor -ErrorAction SilentlyContinue) { return $true } if ($DataHome -and (Test-Path $DataHome)) { return $true } if ($IsMacOS -and (Test-Path '/Applications/Cursor.app')) { return $true } return $false } function ConvertTo-WtwFileUri { [CmdletBinding()] param([Parameter(Mandatory)][string] $Path) $fullPath = [System.IO.Path]::GetFullPath($Path) return ([System.Uri]::new($fullPath)).AbsoluteUri } function ConvertTo-WtwCursorWorkspaceId { [CmdletBinding()] param([Parameter(Mandatory)][string] $WorkspacePath) # VS Code/Cursor define saved-workspace identity as MD5(originalFSPath). # macOS and Windows normalize the path to lower-case; Linux does not. # This must match Cursor exactly because the id also names workspaceStorage. $hashInput = [System.IO.Path]::GetFullPath($WorkspacePath) if (-not $IsLinux) { $hashInput = $hashInput.ToLowerInvariant() } $bytes = [System.Text.Encoding]::UTF8.GetBytes($hashInput) $hash = [System.Security.Cryptography.MD5]::HashData($bytes) return (-join ($hash | ForEach-Object { $_.ToString('x2') })) } function ConvertTo-WtwSqliteLiteral { [CmdletBinding()] param([AllowNull()][string] $Value) if ($null -eq $Value) { return 'NULL' } return "'" + $Value.Replace("'", "''") + "'" } function Get-WtwSqliteCommand { [CmdletBinding()] param() return (Get-Command sqlite3 -ErrorAction SilentlyContinue)?.Source } function Read-WtwCursorStateValue { [CmdletBinding()] param( [Parameter(Mandatory)][string] $StatePath, [Parameter(Mandatory)][string] $Key ) if (-not (Test-Path $StatePath)) { return $null } $sqlite = Get-WtwSqliteCommand if (-not $sqlite) { return $null } $keyLiteral = ConvertTo-WtwSqliteLiteral $Key $raw = & $sqlite -readonly $StatePath "select value from ItemTable where key = $keyLiteral limit 1;" 2>$null if ($LASTEXITCODE -ne 0) { return $null } return ($raw -join [Environment]::NewLine) } function Test-WtwCursorAppRunning { [CmdletBinding()] param() return [bool](Get-Process -Name 'Cursor' -ErrorAction SilentlyContinue) } function Stop-WtwCursorProcess { [CmdletBinding()] param([int] $TimeoutSeconds = 10) $processes = @(Get-Process -Name 'Cursor' -ErrorAction SilentlyContinue) if ($processes.Count -eq 0) { return $true } foreach ($process in $processes) { try { $process.CloseMainWindow() | Out-Null } catch { } } $deadline = (Get-Date).AddSeconds($TimeoutSeconds) while ((Get-Date) -lt $deadline) { if (-not (Test-WtwCursorAppRunning)) { return $true } Start-Sleep -Milliseconds 250 } foreach ($process in @(Get-Process -Name 'Cursor' -ErrorAction SilentlyContinue)) { try { $process.Kill($true) } catch { try { $process.Kill() } catch { } } } $deadline = (Get-Date).AddSeconds(5) while ((Get-Date) -lt $deadline) { if (-not (Test-WtwCursorAppRunning)) { return $true } Start-Sleep -Milliseconds 250 } return -not (Test-WtwCursorAppRunning) } function Resolve-WtwCursorStateConflict { [CmdletBinding()] param([string] $PrettyName = 'the WTW workspace') if (-not (Test-WtwCursorAppRunning)) { return $true } Write-Host '' Write-Host ' Cursor is running — it can overwrite Agents workspace metadata on exit.' -ForegroundColor Yellow Write-Host " Close Cursor once to migrate the Agents label to '$PrettyName'." -ForegroundColor Yellow Write-Host ' [c] Close Cursor yourself, then migrate (I will wait)' Write-Host ' [k] Force-close Cursor, then migrate' Write-Host ' [s] Skip — open with the existing Agents label' $answer = (Read-Host ' Choice [c/k/s]').Trim().ToLowerInvariant() if (-not $answer) { $answer = 'c' } switch ($answer) { 'c' { Write-Host ' Waiting for Cursor to close (Ctrl+C to abort)...' -ForegroundColor Cyan while (Test-WtwCursorAppRunning) { Start-Sleep -Milliseconds 500 } Write-Host ' Cursor closed.' -ForegroundColor Green return $true } 'k' { Write-Host ' Force-closing Cursor...' -ForegroundColor Cyan if (-not (Stop-WtwCursorProcess)) { Write-Host ' Could not stop Cursor — skipping Agents label migration.' -ForegroundColor Red return $false } Write-Host ' Cursor stopped.' -ForegroundColor Green return $true } default { Write-Host ' Skipped Cursor Agents label migration.' -ForegroundColor DarkGray return $false } } } function Get-WtwCursorPrettyWorkspacePath { [CmdletBinding()] param( [Parameter(Mandatory)][string] $WorkspacePath, [Parameter(Mandatory)][string] $PrettyName, [string] $RepoName ) $workspaceDirectory = Split-Path ([System.IO.Path]::GetFullPath($WorkspacePath)) -Parent $stem = ConvertTo-WtwWorkspaceFileStem -Name $PrettyName $candidate = Join-Path $workspaceDirectory "$stem.code-workspace" if (-not (Test-Path $candidate) -or [System.IO.Path]::GetFullPath($candidate) -eq [System.IO.Path]::GetFullPath($WorkspacePath)) { return $candidate } if ($RepoName) { $stem = ConvertTo-WtwWorkspaceFileStem -Name "$RepoName — $PrettyName" return (Join-Path $workspaceDirectory "$stem.code-workspace") } return $candidate } function Move-WtwCursorWorkspaceForAgents { <# .SYNOPSIS Rename a saved workspace while preserving Cursor Agents project history. .DESCRIPTION Cursor's Agents window renders a saved workspace from the .code-workspace filename. Renaming it changes Cursor's MD5 workspace id, so this migrates the workspace file, workspaceStorage directory, recent paths, workspace metadata, and local Agents project identifiers together. Cursor must be closed by the caller. #> [CmdletBinding()] param( [Parameter(Mandatory)][string] $WorkspacePath, [Parameter(Mandatory)][string] $PrettyName, [string] $RepoName, [string] $DataHome = (Get-WtwCursorDataHome) ) $oldPath = [System.IO.Path]::GetFullPath($WorkspacePath) $newPath = Get-WtwCursorPrettyWorkspacePath -WorkspacePath $oldPath -PrettyName $PrettyName -RepoName $RepoName $newPath = [System.IO.Path]::GetFullPath($newPath) if ($oldPath -eq $newPath) { return $oldPath } if (Test-Path $newPath) { Write-Host " Cursor: Agents label target already exists; keeping '$oldPath'." -ForegroundColor Yellow return $oldPath } if (Test-WtwCursorAppRunning) { Write-Host ' Cursor: close Cursor before migrating an existing Agents workspace label.' -ForegroundColor Yellow return $oldPath } $sqlite = Get-WtwSqliteCommand $statePath = Get-WtwCursorGlobalStatePath -DataHome $DataHome if ((Test-Path $statePath) -and -not $sqlite) { Write-Host ' Cursor: sqlite3 is required to preserve Agents history; label migration skipped.' -ForegroundColor Yellow return $oldPath } $oldId = ConvertTo-WtwCursorWorkspaceId -WorkspacePath $oldPath $newId = ConvertTo-WtwCursorWorkspaceId -WorkspacePath $newPath $oldUri = ConvertTo-WtwFileUri -Path $oldPath $newUri = ConvertTo-WtwFileUri -Path $newPath $workspaceStorageRoot = Join-Path $DataHome 'User/workspaceStorage' $oldStoragePath = Join-Path $workspaceStorageRoot $oldId $newStoragePath = Join-Path $workspaceStorageRoot $newId if ((Test-Path $oldStoragePath) -and (Test-Path $newStoragePath)) { Write-Host " Cursor: workspace state already exists for '$newPath'; migration skipped." -ForegroundColor Yellow return $oldPath } $backupRoot = Join-Path $DataHome ('User/wtw-backups/{0}-{1}' -f (Get-Date -Format 'yyyyMMdd-HHmmss'), $oldId) New-Item -Path $backupRoot -ItemType Directory -Force | Out-Null Copy-Item -LiteralPath $oldPath -Destination (Join-Path $backupRoot (Split-Path $oldPath -Leaf)) -Force $keysToMigrate = @( 'history.recentlyOpenedPathsList', 'glass.localAgentProjects.v1', 'workspaceMetadata.entries', '__$__targetStorageMarker' ) $stateValues = [ordered]@{} if (Test-Path $statePath) { foreach ($key in $keysToMigrate) { $stateValues[$key] = Read-WtwCursorStateValue -StatePath $statePath -Key $key } [ordered]@{ oldPath = $oldPath newPath = $newPath oldId = $oldId newId = $newId values = [ordered]@{ 'history.recentlyOpenedPathsList' = $stateValues['history.recentlyOpenedPathsList'] 'glass.localAgentProjects.v1' = $stateValues['glass.localAgentProjects.v1'] 'workspaceMetadata.entries' = $stateValues['workspaceMetadata.entries'] } } | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath (Join-Path $backupRoot 'cursor-state-rows.json') -Encoding utf8 } $storageJsonPath = Join-Path $DataHome 'User/globalStorage/storage.json' if (Test-Path $storageJsonPath) { Copy-Item -LiteralPath $storageJsonPath -Destination (Join-Path $backupRoot 'storage.json') -Force } $movedWorkspace = $false $movedStorage = $false try { Move-Item -LiteralPath $oldPath -Destination $newPath $movedWorkspace = $true if (Test-Path $oldStoragePath) { Move-Item -LiteralPath $oldStoragePath -Destination $newStoragePath $movedStorage = $true $workspaceJsonPath = Join-Path $newStoragePath 'workspace.json' if (Test-Path $workspaceJsonPath) { $workspaceJson = Get-Content -LiteralPath $workspaceJsonPath -Raw $workspaceJson.Replace($oldUri, $newUri).Replace($oldPath, $newPath) | Set-Content -LiteralPath $workspaceJsonPath -Encoding utf8 } } if (Test-Path $storageJsonPath) { $storageJson = Get-Content -LiteralPath $storageJsonPath -Raw $storageJson.Replace($oldUri, $newUri).Replace($oldPath, $newPath).Replace($oldId, $newId) | Set-Content -LiteralPath $storageJsonPath -Encoding utf8 } if (Test-Path $statePath) { $statements = [System.Collections.Generic.List[string]]::new() $statements.Add('begin immediate;') foreach ($key in $keysToMigrate) { $value = $stateValues[$key] if ([string]::IsNullOrEmpty($value)) { continue } $updatedValue = $value.Replace($oldUri, $newUri).Replace($oldPath, $newPath).Replace($oldId, $newId) $updateStatement = 'update ItemTable set value = {0} where key = {1};' -f @( (ConvertTo-WtwSqliteLiteral $updatedValue), (ConvertTo-WtwSqliteLiteral $key) ) $statements.Add($updateStatement) } $oldIdLiteral = ConvertTo-WtwSqliteLiteral $oldId $newIdLiteral = ConvertTo-WtwSqliteLiteral $newId $statements.Add(@" insert or replace into ItemTable (key, value) select replace(key, $oldIdLiteral, $newIdLiteral), value from ItemTable where instr(key, $oldIdLiteral) > 0 and (key like 'agentData.cacheStorage.%' or key like 'cursor/glass.%'); "@) $statements.Add(@" delete from ItemTable where instr(key, $oldIdLiteral) > 0 and (key like 'agentData.cacheStorage.%' or key like 'cursor/glass.%'); "@) $statements.Add('commit;') & $sqlite $statePath ($statements -join [Environment]::NewLine) 2>$null if ($LASTEXITCODE -ne 0) { throw 'Cursor state database migration failed.' } } } catch { if (Test-Path (Join-Path $backupRoot 'storage.json')) { Copy-Item -LiteralPath (Join-Path $backupRoot 'storage.json') -Destination $storageJsonPath -Force } if ($movedStorage -and (Test-Path $newStoragePath) -and -not (Test-Path $oldStoragePath)) { $workspaceJsonPath = Join-Path $newStoragePath 'workspace.json' if (Test-Path $workspaceJsonPath) { $workspaceJson = Get-Content -LiteralPath $workspaceJsonPath -Raw $workspaceJson.Replace($newUri, $oldUri).Replace($newPath, $oldPath) | Set-Content -LiteralPath $workspaceJsonPath -Encoding utf8 } Move-Item -LiteralPath $newStoragePath -Destination $oldStoragePath } if ($movedWorkspace -and (Test-Path $newPath) -and -not (Test-Path $oldPath)) { Move-Item -LiteralPath $newPath -Destination $oldPath } Write-Host " Cursor: Agents label migration failed: $($_.Exception.Message)" -ForegroundColor Red return $oldPath } Write-Host " Cursor: Agents workspace label '$PrettyName'" -ForegroundColor Green Write-Host " Cursor: migration backup $backupRoot" -ForegroundColor DarkGray return $newPath } function Read-WtwCursorRecentlyOpenedState { [CmdletBinding()] param([Parameter(Mandatory)][string] $StatePath) if (-not (Test-Path $StatePath)) { return [PSCustomObject]@{ entries = @() } } $sqlite = Get-WtwSqliteCommand if (-not $sqlite) { Write-Host ' Cursor: sqlite3 not found - skipping recent workspace registration.' -ForegroundColor DarkGray return $null } $sql = "select value from ItemTable where key = 'history.recentlyOpenedPathsList' limit 1;" $value = & $sqlite $StatePath $sql 2>$null if ($LASTEXITCODE -ne 0) { Write-Host ' Cursor: could not read state.vscdb - skipping recent workspace registration.' -ForegroundColor Yellow return $null } $raw = ($value -join [Environment]::NewLine) if ([string]::IsNullOrWhiteSpace($raw)) { return [PSCustomObject]@{ entries = @() } } try { return $raw | ConvertFrom-Json } catch { Write-Host ' Cursor: could not parse recently-opened state - skipping update.' -ForegroundColor Yellow return $null } } function Save-WtwCursorRecentlyOpenedState { [CmdletBinding()] param( [Parameter(Mandatory)][PSObject] $State, [Parameter(Mandatory)][string] $StatePath ) $sqlite = Get-WtwSqliteCommand if (-not $sqlite) { Write-Host ' Cursor: sqlite3 not found - skipping recent workspace registration.' -ForegroundColor DarkGray return $false } $stateDir = Split-Path $StatePath -Parent if (-not (Test-Path $stateDir)) { New-Item -Path $stateDir -ItemType Directory -Force | Out-Null } $json = $State | ConvertTo-Json -Depth 80 -Compress $jsonLiteral = ConvertTo-WtwSqliteLiteral $json $sql = @( 'create table if not exists ItemTable (key TEXT, value BLOB);', "delete from ItemTable where key = 'history.recentlyOpenedPathsList';", "insert into ItemTable (key, value) values ('history.recentlyOpenedPathsList', $jsonLiteral);" ) -join [Environment]::NewLine & $sqlite $StatePath $sql 2>$null if ($LASTEXITCODE -ne 0) { Write-Host ' Cursor: could not save state.vscdb - skipping recent workspace registration.' -ForegroundColor Yellow return $false } return $true } function Set-WtwCursorRecentWorkspace { [CmdletBinding()] param( [Parameter(Mandatory)][string] $WorkspacePath, [string] $StatePath = (Get-WtwCursorGlobalStatePath) ) $fullWorkspacePath = [System.IO.Path]::GetFullPath($WorkspacePath) $workspaceUri = ConvertTo-WtwFileUri -Path $fullWorkspacePath $workspaceId = ConvertTo-WtwCursorWorkspaceId -WorkspacePath $fullWorkspacePath $state = Read-WtwCursorRecentlyOpenedState -StatePath $StatePath if (-not $state) { return $false } $entry = [PSCustomObject]@{ workspace = [PSCustomObject]@{ id = $workspaceId configPath = $workspaceUri } } $entries = @() if ((Get-WtwPropertyNames -Object $state) -contains 'entries' -and $state.entries) { $entries = @($state.entries) } $entries = @($entries | Where-Object { $existingWorkspace = if ((Get-WtwPropertyNames -Object $_) -contains 'workspace') { $_.workspace } else { $null } $existingConfigPath = if ($existingWorkspace -and (Get-WtwPropertyNames -Object $existingWorkspace) -contains 'configPath') { $existingWorkspace.configPath } else { $null } $existingConfigPath -ne $workspaceUri }) $state | Add-Member -NotePropertyName 'entries' -NotePropertyValue (@($entry) + $entries) -Force return (Save-WtwCursorRecentlyOpenedState -State $state -StatePath $StatePath) } function Remove-WtwCursorRecentWorkspace { [CmdletBinding()] param( [Parameter(Mandatory)][string] $WorkspacePath, [string] $StatePath = (Get-WtwCursorGlobalStatePath) ) if (-not (Test-Path $StatePath)) { return $false } $workspaceUri = ConvertTo-WtwFileUri -Path $WorkspacePath $state = Read-WtwCursorRecentlyOpenedState -StatePath $StatePath if (-not $state) { return $false } $entries = @() if ((Get-WtwPropertyNames -Object $state) -contains 'entries' -and $state.entries) { $entries = @($state.entries) } $filtered = @($entries | Where-Object { $existingWorkspace = if ((Get-WtwPropertyNames -Object $_) -contains 'workspace') { $_.workspace } else { $null } $existingConfigPath = if ($existingWorkspace -and (Get-WtwPropertyNames -Object $existingWorkspace) -contains 'configPath') { $existingWorkspace.configPath } else { $null } $existingConfigPath -ne $workspaceUri }) if ($filtered.Count -eq $entries.Count) { return $false } $state | Add-Member -NotePropertyName 'entries' -NotePropertyValue $filtered -Force return (Save-WtwCursorRecentlyOpenedState -State $state -StatePath $StatePath) } function Register-WtwCursorProject { <# .SYNOPSIS Register a generated code-workspace in Cursor's recent workspace list. .DESCRIPTION Cursor stores recent workspaces in VS Code's SQLite-backed history.recentlyOpenedPathsList. The schema does not expose a stable custom label field; Cursor renders the workspace from the .code-workspace path/name. Colors are carried by the workspace file's workbench.colorCustomizations / peacock.color settings. #> [CmdletBinding()] param( [Parameter(Mandatory)][string] $WorkspacePath, [string] $ProjectPath, [string] $PrettyName, [string] $Color, [string] $StatePath ) if (-not (Test-Path $WorkspacePath)) { return $null } if (-not (Test-WtwCursorPresent)) { Write-Host ' Cursor: not installed/present - skipping recent workspace registration.' -ForegroundColor DarkGray return $null } $splat = @{ WorkspacePath = $WorkspacePath } if ($StatePath) { $splat.StatePath = $StatePath } if (Set-WtwCursorRecentWorkspace @splat) { $label = if ($PrettyName) { " '$PrettyName'" } else { '' } $colorLabel = if ($Color) { " ($Color)" } else { '' } Write-Host " Cursor: registered recent workspace${label}${colorLabel}" -ForegroundColor Green return [System.IO.Path]::GetFullPath($WorkspacePath) } return $null } function Unregister-WtwCursorProject { [CmdletBinding()] param( [Parameter(Mandatory)][string] $WorkspacePath, [string] $StatePath ) $splat = @{ WorkspacePath = $WorkspacePath } if ($StatePath) { $splat.StatePath = $StatePath } if (Remove-WtwCursorRecentWorkspace @splat) { Write-Host ' Cursor: removed recent workspace metadata.' -ForegroundColor Green } } |