Public/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 ConvertFrom-CopilotWorkspaceBlockScalar {
    param(
        [Parameter(Mandatory)][AllowEmptyString()][string[]]$Lines,
        [Parameter(Mandatory)][int]$StartIndex,
        [Parameter(Mandatory)][int]$BaseIndent,
        [Parameter(Mandatory)][ValidateSet('|', '>')][string]$Style,
        [string]$Chomp
    )

    $blockLines = [System.Collections.Generic.List[string]]::new()
    $blockIndent = $null

    for ($i = $StartIndex + 1; $i -lt $Lines.Count; $i++) {
        $line = $Lines[$i]
        if ($line -match '^[ \t]*$') {
            $blockLines.Add('')
            continue
        }

        $indent = Get-CopilotWorkspaceIndentLength $line
        if ($indent -le $BaseIndent) { break }
        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
    )

    $escapedField = [regex]::Escape($Field)
    for ($i = 0; $i -lt $Lines.Count; $i++) {
        if ($Lines[$i] -match "^(?<indent>[ \t]*)$escapedField\s*:\s*(?<value>.*)$") {
            return [PSCustomObject]@{
                Index  = $i
                Indent = $Matches['indent']
                Value  = $Matches['value']
            }
        }
    }

    $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 >-. The parser accepts either raw content or a path.
 
    .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 }

        $value = $match.Value.Trim()
        if ($value -match '^(?<style>[|>])(?<chomp>[-+]?)\s*(?:#.*)?$') {
            return ConvertFrom-CopilotWorkspaceBlockScalar `
                -Lines $lines `
                -StartIndex $match.Index `
                -BaseIndent $match.Indent.Length `
                -Style $Matches['style'] `
                -Chomp $Matches['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. 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 = $match.Index + 1
        if ($match.Value.Trim() -match '^[|>]') {
            for (; $endIndex -lt $lines.Count; $endIndex++) {
                $line = $lines[$endIndex]
                if ($line -match '^[ \t]*$') { continue }
                if ((Get-CopilotWorkspaceIndentLength $line) -le $match.Indent.Length) { break }
            }
        }

        $scalar = ConvertTo-CopilotWorkspaceFlowScalar $Value
        $replacement = "$($match.Indent)${Field}: $scalar" -replace "`n", $newLine
        $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
    }
}