private/Invoke-WtwClaudeProject.ps1

function Get-WtwClaudeMacAppName {
    <#
    .SYNOPSIS
        First installed Claude desktop app bundle name, or $null.
    #>

    [CmdletBinding()]
    param(
        [string[]] $Candidates = @('Claude')
    )

    if (-not $IsMacOS) { return $null }

    foreach ($candidate in $Candidates) {
        if ($candidate -and (Test-Path "/Applications/$candidate.app")) { return $candidate }
    }

    return $null
}

function Get-WtwClaudeCodeConfigPath {
    <#
    .SYNOPSIS
        Path to Claude Code's per-user config file (`~/.claude.json`).
    .DESCRIPTION
        Honours $env:CLAUDE_CONFIG_DIR, which relocates the whole Claude Code
        config directory. Returns the path whether or not the file exists —
        callers decide what to do when it is missing.
    #>

    [CmdletBinding()]
    param()

    $root = if ($env:CLAUDE_CONFIG_DIR) { $env:CLAUDE_CONFIG_DIR } else { $HOME }
    return [System.IO.Path]::GetFullPath((Join-Path $root '.claude.json'))
}

function Set-WtwClaudeCodeProjectTrust {
    <#
    .SYNOPSIS
        Set or clear the "Trust this workspace?" answer for a directory.
    .DESCRIPTION
        Claude Code stores the trust decision in `~/.claude.json` under
        `projects.<abs-path>.hasTrustDialogAccepted`. Pre-seeding it means a
        freshly created worktree does not re-prompt the first time it is opened.

        That file is written live by every running Claude Code session, so this
        keeps the read/modify/write window as short as possible and swaps the
        file in via an atomic rename instead of truncating it in place. It is a
        no-op when the file does not exist — wtw does not bootstrap Claude Code's
        config, mirroring how the Superset/Codex/cmux registrations no-op when
        those tools are absent.
    .PARAMETER ProjectPath
        Directory to trust (or un-trust).
    .PARAMETER Remove
        Drop the whole `projects.<path>` entry instead of trusting it. Used by
        `wtw remove`, where the directory itself is going away.
    .OUTPUTS
        The normalized project path on success, otherwise $null.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $ProjectPath,

        [switch] $Remove
    )

    $configPath = Get-WtwClaudeCodeConfigPath
    if (-not (Test-Path $configPath)) { return $null }

    $fullPath = [System.IO.Path]::GetFullPath($ProjectPath)

    try {
        $config = Get-Content -Path $configPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
    } catch {
        Write-Warning "Claude Code: could not read $configPath — skipping trust update. ($($_.Exception.Message))"
        return $null
    }

    if (-not $config) { return $null }

    $projects = Get-WtwPropertyValue -Object $config -Name 'projects'
    if ($Remove) {
        if (-not $projects) { return $null }
        if ((Get-WtwPropertyNames -Object $projects) -notcontains $fullPath) { return $null }
        $projects.PSObject.Properties.Remove($fullPath)
    } else {
        if (-not $projects) {
            $projects = [PSCustomObject]@{}
            $config | Add-Member -NotePropertyName 'projects' -NotePropertyValue $projects -Force
        }

        $entry = Get-WtwPropertyValue -Object $projects -Name $fullPath
        if (-not $entry) {
            $entry = [PSCustomObject]@{}
            $projects | Add-Member -NotePropertyName $fullPath -NotePropertyValue $entry -Force
        }

        if ((Get-WtwPropertyValue -Object $entry -Name 'hasTrustDialogAccepted') -eq $true) {
            return $fullPath   # already trusted — leave the file untouched
        }

        $entry | Add-Member -NotePropertyName 'hasTrustDialogAccepted' -NotePropertyValue $true -Force
    }

    # -Depth 100: the default of 2 would silently flatten nested project state
    # (history, MCP server config) into type names and corrupt the file.
    try {
        $json = $config | ConvertTo-Json -Depth 100
    } catch {
        Write-Warning "Claude Code: could not serialize $configPath — skipping trust update. ($($_.Exception.Message))"
        return $null
    }

    $temp = "$configPath.wtw-$PID.tmp"
    try {
        Set-Content -Path $temp -Value $json -NoNewline -ErrorAction Stop
        Move-Item -Path $temp -Destination $configPath -Force -ErrorAction Stop
    } catch {
        Remove-Item -Path $temp -Force -ErrorAction SilentlyContinue
        Write-Warning "Claude Code: could not write $configPath — trust unchanged. ($($_.Exception.Message))"
        return $null
    }

    return $fullPath
}

function Register-WtwClaudeCodeProject {
    <#
    .SYNOPSIS
        Pre-accept Claude Code's workspace-trust prompt for a new worktree.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $ProjectPath
    )

    return Set-WtwClaudeCodeProjectTrust -ProjectPath $ProjectPath
}

function Unregister-WtwClaudeCodeProject {
    <#
    .SYNOPSIS
        Drop a removed worktree's Claude Code project state.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $ProjectPath
    )

    return Set-WtwClaudeCodeProjectTrust -ProjectPath $ProjectPath -Remove
}

function Test-WtwClaudeAppRunning {
    <#
    .SYNOPSIS
        True when the Claude desktop app already has a process running.
    #>

    [CmdletBinding()]
    param(
        [string] $AppName = 'Claude'
    )

    if ($IsMacOS) {
        # pgrep does not see the main Electron process on macOS — `pgrep -x Claude`
        # and `pgrep -f <bundle path>` both miss it while still matching the
        # "Claude Helper" children. Match the exact inner binary in `ps` instead;
        # helpers live under Contents/Frameworks/, so anchoring the whole path
        # excludes them.
        $binary = "/Applications/$AppName.app/Contents/MacOS/$AppName"
        $processes = @(& ps -axo comm= 2>$null)
        return [bool] ($processes | Where-Object { $_ -eq $binary })
    }

    return [bool] (Get-Process -Name $AppName -ErrorAction SilentlyContinue)
}

function New-WtwClaudeCodeDeepLink {
    <#
    .SYNOPSIS
        Build a `claude://code/new` deep link for a project directory.
    .DESCRIPTION
        Claude for Desktop registers the `claude://` URL scheme. Its deep-link
        router accepts host `code` with path `/new`, and forwards exactly three
        query parameters to the new-session screen:

            folder — absolute path the Claude Code session is rooted at (repeatable)
            q — text pre-filled into the composer (also accepted as `prompt`)
            file — read by the handler but currently dropped for `code/new`

        Anything else (a session title, a color) is discarded by the app, so this
        helper only emits folder/q. The app caps `q` at ~14k characters.
    .PARAMETER ProjectPath
        Directory the new Claude Code session should open in.
    .PARAMETER Prompt
        Optional text to pre-fill the composer with (not auto-submitted).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $ProjectPath,

        [string] $Prompt
    )

    $fullPath = [System.IO.Path]::GetFullPath($ProjectPath)
    $query = "folder=$([uri]::EscapeDataString($fullPath))"

    if ($Prompt) {
        $trimmed = if ($Prompt.Length -gt 14000) { $Prompt.Substring(0, 14000) } else { $Prompt }
        $query += "&q=$([uri]::EscapeDataString($trimmed))"
    }

    return "claude://code/new?$query"
}

function Start-WtwClaudeCodeSession {
    <#
    .SYNOPSIS
        Open a new Claude Code chat in the Claude desktop app, rooted at a directory.
    .DESCRIPTION
        Hands the `claude://code/new` deep link to the OS URL opener so the
        registered Claude desktop app handles it — launching the app first when
        it is not already running. Returns a result object rather than throwing so
        callers can print a wtw-styled message.
    .PARAMETER ProjectPath
        Directory the new Claude Code session should open in.
    .PARAMETER Prompt
        Optional text to pre-fill the composer with.
    .PARAMETER AppNameCandidates
        Ordered macOS app bundle names to accept (handles future renames).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $ProjectPath,

        [string] $Prompt,

        [string[]] $AppNameCandidates = @('Claude'),

        [int] $LaunchTimeoutSeconds = 20
    )

    $url = New-WtwClaudeCodeDeepLink -ProjectPath $ProjectPath -Prompt $Prompt

    if ($IsMacOS) {
        # Probe the bundle first so a missing install reports the app name instead
        # of macOS's opaque "no application knows how to open this URL".
        $appName = Get-WtwClaudeMacAppName -Candidates $AppNameCandidates
        if (-not $appName) {
            $tried = ($AppNameCandidates | ForEach-Object { "$_.app" }) -join ', '
            return [PSCustomObject]@{ Success = $false; Url = $url; Reason = "Claude for Desktop is not installed. Tried: $tried" }
        }

        # Cold start drops the link: the app's URL handler gives up when the main
        # webContents is not attached yet, and the app then boots to whatever
        # surface it last showed (usually Chat). Launch first, wait for the window,
        # and re-send once — `code/new` just navigates, so a repeat is harmless.
        $coldStart = -not (Test-WtwClaudeAppRunning -AppName $appName)
        if ($coldStart) {
            Write-Host ' Claude is not running — launching it first...' -ForegroundColor DarkGray
            & open -a $appName
            $deadline = (Get-Date).AddSeconds($LaunchTimeoutSeconds)
            while (-not (Test-WtwClaudeAppRunning -AppName $appName) -and (Get-Date) -lt $deadline) {
                Start-Sleep -Milliseconds 250
            }
            Start-Sleep -Seconds 3
        }

        # Route through LaunchServices (not `open -a`): the scheme handler is what
        # knows how to turn the URL into a new Claude Code session.
        & open $url
        if ($coldStart) {
            Start-Sleep -Seconds 4
            & open $url
        }

        return [PSCustomObject]@{ Success = $true; Url = $url; Reason = $null }
    }

    if ($IsWindows) {
        try {
            Start-Process $url -ErrorAction Stop
            return [PSCustomObject]@{ Success = $true; Url = $url; Reason = $null }
        } catch {
            return [PSCustomObject]@{ Success = $false; Url = $url; Reason = "No handler registered for claude:// — install Claude for Desktop. ($($_.Exception.Message))" }
        }
    }

    $opener = Get-Command xdg-open -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
    if (-not $opener) {
        return [PSCustomObject]@{ Success = $false; Url = $url; Reason = 'No xdg-open on PATH to dispatch the claude:// deep link.' }
    }

    & $opener.Source $url
    return [PSCustomObject]@{ Success = $true; Url = $url; Reason = $null }
}