WorkspaceYaml.ps1
|
function Get-CopilotWorkspaceIndentLength { param([AllowEmptyString()][string]$Line) if ($Line -match '^[ \t]*') { return $Matches[0].Length } return 0 } function Split-CopilotWorkspaceLines { param([AllowEmptyString()][string]$Content) [regex]::Split($Content, '\r\n|\n|\r') } function ConvertFrom-CopilotWorkspaceFlowScalar { param([AllowEmptyString()][string]$Value) $trimmed = $Value.Trim() if ($trimmed.Length -eq 0) { return '' } if ($trimmed[0] -eq "'") { $result = [System.Text.StringBuilder]::new() for ($i = 1; $i -lt $trimmed.Length; $i++) { if ($trimmed[$i] -eq "'") { if ($i + 1 -lt $trimmed.Length -and $trimmed[$i + 1] -eq "'") { [void]$result.Append("'") $i++ continue } return $result.ToString() } [void]$result.Append($trimmed[$i]) } return $result.ToString() } if ($trimmed[0] -eq '"') { $result = [System.Text.StringBuilder]::new() for ($i = 1; $i -lt $trimmed.Length; $i++) { $char = $trimmed[$i] if ($char -eq '"') { return $result.ToString() } if ($char -eq '\' -and $i + 1 -lt $trimmed.Length) { $i++ switch ($trimmed[$i]) { 'n' { [void]$result.Append("`n") } 'r' { [void]$result.Append("`r") } 't' { [void]$result.Append("`t") } '"' { [void]$result.Append('"') } '\' { [void]$result.Append('\') } default { [void]$result.Append($trimmed[$i]) } } continue } [void]$result.Append($char) } return $result.ToString() } $comment = [regex]::Match($trimmed, '\s+#') if ($comment.Success) { return $trimmed.Substring(0, $comment.Index).TrimEnd() } $trimmed } function Get-CopilotWorkspaceBlockScalarInfo { param( [Parameter(Mandatory)][AllowEmptyString()][string[]]$Lines, [Parameter(Mandatory)][int]$StartIndex, [Parameter(Mandatory)][int]$BaseIndent, [AllowEmptyString()][string]$Value ) $header = [regex]::Match($Value.Trim(), '^(?<style>[|>])(?<chomp>[-+]?)\s*(?:#.*)?$') if (-not $header.Success) { return $null } $endIndex = $StartIndex + 1 for (; $endIndex -lt $Lines.Count; $endIndex++) { $line = $Lines[$endIndex] if ($line -match '^[ \t]*$') { continue } if ((Get-CopilotWorkspaceIndentLength $line) -le $BaseIndent) { break } } [PSCustomObject]@{ EndIndex = $endIndex Style = $header.Groups['style'].Value Chomp = $header.Groups['chomp'].Value } } function ConvertFrom-CopilotWorkspaceBlockScalar { param( [Parameter(Mandatory)][AllowEmptyString()][string[]]$Lines, [Parameter(Mandatory)][int]$StartIndex, [Parameter(Mandatory)][int]$EndIndex, [Parameter(Mandatory)][ValidateSet('|', '>')][string]$Style, [string]$Chomp ) $blockLines = [System.Collections.Generic.List[string]]::new() $blockIndent = $null for ($i = $StartIndex + 1; $i -lt $EndIndex; $i++) { $line = $Lines[$i] if ($line -match '^[ \t]*$') { $blockLines.Add('') continue } $indent = Get-CopilotWorkspaceIndentLength $line if ($null -eq $blockIndent) { $blockIndent = $indent } $remove = [Math]::Min($blockIndent, $line.Length) $blockLines.Add($line.Substring($remove)) } if ($Style -eq '|') { $text = $blockLines -join "`n" } else { $foldedLines = [System.Collections.Generic.List[string]]::new() $paragraph = [System.Collections.Generic.List[string]]::new() foreach ($line in $blockLines) { if ($line -eq '') { if ($paragraph.Count -gt 0) { $foldedLines.Add(($paragraph -join ' ')) $paragraph.Clear() } $foldedLines.Add('') } else { $paragraph.Add($line.TrimEnd()) } } if ($paragraph.Count -gt 0) { $foldedLines.Add(($paragraph -join ' ')) } $text = $foldedLines -join "`n" } if ($Chomp -ne '-') { $text += "`n" } $text } function Find-CopilotWorkspaceFieldLine { param( [Parameter(Mandatory)][AllowEmptyString()][string[]]$Lines, [Parameter(Mandatory)][string]$Field ) for ($i = 0; $i -lt $Lines.Count; $i++) { $mapping = [regex]::Match($Lines[$i], '^(?<indent>[ \t]*)(?<field>[^:#]+?)\s*:\s*(?<value>.*)$') if (-not $mapping.Success) { continue } $indent = $mapping.Groups['indent'].Value $value = $mapping.Groups['value'].Value $block = Get-CopilotWorkspaceBlockScalarInfo -Lines $Lines -StartIndex $i -BaseIndent $indent.Length -Value $value if ($mapping.Groups['field'].Value -eq $Field) { return [PSCustomObject]@{ Index = $i Indent = $indent Value = $value Block = $block } } if ($null -ne $block) { $i = $block.EndIndex - 1 } } $null } function ConvertTo-CopilotWorkspaceFlowScalar { param([AllowEmptyString()][string]$Value) if ($Value -match '\r|\n') { $lines = Split-CopilotWorkspaceLines $Value if ($lines.Count -gt 0 -and $lines[-1] -eq '') { $lines = $lines[0..($lines.Count - 2)] } return "|-`n $($lines -join "`n ")" } if ($Value -eq '') { return '""' } $needsQuotes = $Value -match '^\s|\s$|[:#\[\]{},&*!?|>''"%@`]|\s#|:\s' if (-not $needsQuotes) { return $Value } $escaped = $Value.Replace('\', '\\').Replace('"', '\"').Replace("`r", '\r').Replace("`n", '\n').Replace("`t", '\t') '"' + $escaped + '"' } function Get-CopilotWorkspaceField { <# .SYNOPSIS Reads a scalar field from Copilot CLI workspace.yaml content. .DESCRIPTION Parses the simple top-level workspace.yaml shape used by Copilot CLI session metadata. Supports plain scalars, single-quoted and double-quoted flow scalars, and literal or folded block scalars using |, |-, >, or >-. Block bodies, including blank lines and mapping-looking text, are not metadata fields. Accepts raw content or a path. This is not a general YAML parser; explicit block indentation indicators and nested mappings are outside the supported workspace shape. .PARAMETER Field The workspace.yaml field name to read. .PARAMETER Content Raw workspace.yaml content. .PARAMETER Path Path to a workspace.yaml file. .EXAMPLE Get-CopilotWorkspaceField -Field name -Content 'name: "Build tests"' Returns Build tests. #> [CmdletBinding(DefaultParameterSetName = 'Content')] param( [Parameter(Mandatory, Position = 0)] [ValidateNotNullOrEmpty()] [string]$Field, [Parameter(Mandatory, ParameterSetName = 'Content', ValueFromPipeline)] [AllowEmptyString()] [string]$Content, [Parameter(Mandatory, ParameterSetName = 'Path')] [ValidateNotNullOrEmpty()] [string]$Path ) process { if ($PSCmdlet.ParameterSetName -eq 'Path') { if (-not (Test-Path -LiteralPath $Path)) { return $null } $Content = Get-Content -LiteralPath $Path -Raw } $lines = Split-CopilotWorkspaceLines $Content $match = Find-CopilotWorkspaceFieldLine -Lines $lines -Field $Field if ($null -eq $match) { return $null } if ($null -ne $match.Block) { return ConvertFrom-CopilotWorkspaceBlockScalar ` -Lines $lines ` -StartIndex $match.Index ` -EndIndex $match.Block.EndIndex ` -Style $match.Block.Style ` -Chomp $match.Block.Chomp } ConvertFrom-CopilotWorkspaceFlowScalar $match.Value } } function Set-CopilotWorkspaceField { <# .SYNOPSIS Rewrites a scalar field in Copilot CLI workspace.yaml content. .DESCRIPTION Replaces an existing workspace.yaml field while preserving the field's indentation and the file's dominant line ending. Existing block scalar bodies are removed before the replacement is written; field-like text inside other blocks is left untouched. Single-line values are emitted as plain scalars when safe and double-quoted otherwise; multi-line values are emitted as a literal block scalar. .PARAMETER Field The workspace.yaml field name to rewrite. .PARAMETER Value The replacement scalar value. .PARAMETER Content Raw workspace.yaml content. .PARAMETER Path Path to a workspace.yaml file to update in place. .EXAMPLE Set-CopilotWorkspaceField -Field name -Value 'Merged session' -Content $yaml Returns updated workspace.yaml content. #> [CmdletBinding(DefaultParameterSetName = 'Content')] param( [Parameter(Mandatory, Position = 0)] [ValidateNotNullOrEmpty()] [string]$Field, [Parameter(Mandatory, Position = 1)] [AllowEmptyString()] [string]$Value, [Parameter(Mandatory, ParameterSetName = 'Content', ValueFromPipeline)] [AllowEmptyString()] [string]$Content, [Parameter(Mandatory, ParameterSetName = 'Path')] [ValidateNotNullOrEmpty()] [string]$Path ) process { if ($PSCmdlet.ParameterSetName -eq 'Path') { if (-not (Test-Path -LiteralPath $Path)) { return $null } $Content = Get-Content -LiteralPath $Path -Raw } $newLine = if ($Content -match "`r`n") { "`r`n" } elseif ($Content -match "`r") { "`r" } else { "`n" } $lines = Split-CopilotWorkspaceLines $Content $match = Find-CopilotWorkspaceFieldLine -Lines $lines -Field $Field if ($null -eq $match) { return $Content } $endIndex = if ($null -ne $match.Block) { $match.Block.EndIndex } else { $match.Index + 1 } $scalar = ConvertTo-CopilotWorkspaceFlowScalar $Value $replacement = "$($match.Indent)${Field}: $scalar".Replace("`n", "$newLine$($match.Indent)") $replacementLines = Split-CopilotWorkspaceLines $replacement $updatedLines = [System.Collections.Generic.List[string]]::new() for ($i = 0; $i -lt $match.Index; $i++) { $updatedLines.Add($lines[$i]) } foreach ($line in $replacementLines) { $updatedLines.Add($line) } for ($i = $endIndex; $i -lt $lines.Count; $i++) { $updatedLines.Add($lines[$i]) } $updated = $updatedLines -join $newLine if ($PSCmdlet.ParameterSetName -eq 'Path') { Set-Content -LiteralPath $Path -Value $updated -Encoding UTF8 -NoNewline } $updated } } |