src/Editors.ps1

# ---------------------------------------------------------------------------
# Editors -- discovering them, measuring them, and isolating them
# ---------------------------------------------------------------------------
#
# THE PROBLEM
#
# An editor stores its sign-ins per profile directory. VS Code encrypts them
# with DPAPI into state.vscdb inside --user-data-dir; every VS Code descendant
# does the same. So one profile directory per context means independent GitHub,
# Copilot and marketplace sessions, live at the same time -- and no profile
# directory means they all share one, which is why signing into GitHub for a
# client project signs you out of your own.
#
# The module already knew this: Open-DevCode has passed --user-data-dir since
# August 2026. What it did not do is survive a launcher it did not write. A
# shortcut created by hand, `code .` typed in a terminal, "Open with" from the
# file explorer, an npm script, an agent -- none of those go through
# Open-DevCode, and all of them land on the shared profile.
#
# WHY THIS IS DISCOVERY AND NOT A LIST
#
# The first draft of this file was a table: name, executable, flags. It was
# wrong within the hour, on this very machine.
#
# - Cursor ships resources/app/codeBin/code.cmd. A table keyed on the name
# `code` would have isolated Cursor's profile and called it VS Code.
# - Antigravity accepts --user-data-dir but has no --extensions-dir and no
# --list-extensions at all. "It is a VS Code fork, therefore it takes the
# VS Code flags" is exactly the kind of inference that produces a command
# line the editor silently ignores.
#
# And a table is keyed on ONE machine. Someone else has Positron, or VSCodium,
# or a build of Cursor from a path we never guessed. So: the names below are
# search HINTS, never a truth table. What an editor supports is measured, and
# what cannot be measured is reported as unmeasured rather than assumed.
#
# WHY THE PROBE NEVER LAUNCHES A GUI
#
# Recorded on 15 Aug 2026, and the reason Test-CtxEditorCapabilities is built
# the way it is. Probing Antigravity by running its .exe with CLI flags did not
# print a version -- it opened the editor, which then relaunched itself after an
# update and threw "EPIPE: broken pipe" in a loop, because the console that
# started it had gone. A diagnostic that opens windows on someone's machine, or
# leaves an application crashing behind it, is not a diagnostic.
#
# So the probe runs a binary only when the install layout proves a command-line
# entry point exists -- a bin/<name>.cmd, the signature of the VS Code family.
# Otherwise it reads the application's own argument surface from disk and says
# so. Declared is weaker than measured, and the difference is reported, never
# smoothed over.

# ---------------------------------------------------------------------------
# Search hints
# ---------------------------------------------------------------------------
#
# Command name, human label, and the profile sub-directory inside the context.
#
# `code` maps to 'vscode' rather than 'code' because Open-DevCode has written
# there since August 2026 and real sign-ins live in it. Renaming the directory
# to be tidier would log every existing user out of every context at once --
# the precise failure this module exists to prevent, caused by the fix for it.

$script:EditorHints = @(
    [pscustomobject]@{ Name = 'code';          Label = 'Visual Studio Code'; Profile = 'vscode' }
    [pscustomobject]@{ Name = 'code-insiders'; Label = 'VS Code Insiders';   Profile = 'vscode-insiders' }
    [pscustomobject]@{ Name = 'codium';        Label = 'VSCodium';           Profile = 'vscodium' }
    [pscustomobject]@{ Name = 'cursor';        Label = 'Cursor';             Profile = 'cursor' }
    [pscustomobject]@{ Name = 'windsurf';      Label = 'Windsurf';           Profile = 'windsurf' }
    [pscustomobject]@{ Name = 'antigravity';   Label = 'Antigravity';        Profile = 'antigravity' }
    [pscustomobject]@{ Name = 'trae';          Label = 'Trae';               Profile = 'trae' }
    [pscustomobject]@{ Name = 'positron';      Label = 'Positron';           Profile = 'positron' }
    [pscustomobject]@{ Name = 'kiro';          Label = 'Kiro';               Profile = 'kiro' }
)

# The flags this module knows how to isolate with. Both belong to the VS Code
# command line and are inherited by its descendants; an editor from another
# family supports neither, which the probe reports rather than guesses.
$script:EditorIsolationFlags = @('--user-data-dir', '--extensions-dir')

function Get-CtxEditorHints {
    <#
      The built-in hints, plus anything the user declared.

      Declared editors come first: someone who took the trouble to name their
      own build means it to win over a hint that happens to share the name.
    #>

    param([string]$ContextRoot = $script:CtxRoot)

    $declared = @()
    $file = [System.IO.Path]::Combine($ContextRoot, 'editors.json')
    if (Test-Path -LiteralPath $file) {
        try {
            $raw = Get-Content -LiteralPath $file -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
            foreach ($e in @($raw)) {
                $name = Get-CtxProp $e 'name'
                if (-not $name) { continue }
                $declared += [pscustomobject]@{
                    Name    = $name
                    Label   = Get-CtxProp $e 'label' $name
                    Profile = Get-CtxProp $e 'profile' $name
                    Command = Get-CtxProp $e 'command'
                }
            }
        }
        catch {
            # An unreadable file must not take the built-in hints down with it.
            Write-Verbose "editors.json illisible : $($_.Exception.Message)"
        }
    }

    $seen = @{}
    foreach ($e in @($declared) + @($script:EditorHints)) {
        $key = $e.Name.ToLowerInvariant()
        if ($seen.ContainsKey($key)) { continue }
        $seen[$key] = $true
        $e
    }
}

# ---------------------------------------------------------------------------
# Discovery -- looking, never running
# ---------------------------------------------------------------------------

function Get-CtxEditorInstallRoots {
    <#
      Where editors of this family install themselves, per platform.

      Get-Command already covers anything on PATH, which is the common case.
      This exists for the rest: Antigravity installs no CLI launcher at all, so
      PATH knows nothing about it and only the install directory does.
    #>

    $roots = @()
    if ($IsWindows -or $env:OS -eq 'Windows_NT') {
        $roots += Join-Path $env:LOCALAPPDATA 'Programs'
        $roots += $env:ProgramFiles
        $roots += ${env:ProgramFiles(x86)}
    }
    elseif ($IsMacOS) { $roots += '/Applications', (Join-Path $HOME 'Applications') }
    else              { $roots += '/usr/share', '/opt', (Join-Path $HOME '.local/share') }

    $roots | Where-Object { $_ -and (Test-Path -LiteralPath $_) }
}

function Find-CtxEditorCli {
    <#
      The command-line entry point, and it must not be one of our own shims.

      Same exclusion as the supabase shim, for the same reason: once shims sit
      first in PATH, resolving a name by PATH finds OURSELVES, and an editor
      shim that delegates to an editor shim never reaches an editor.
    #>

    param(
        [Parameter(Mandatory)][string]$Name,
        [string]$ShimDir = (Join-Path $PSScriptRoot '..' 'shims')
    )

    $shim = ''
    if (Test-Path -LiteralPath $ShimDir) {
        $shim = (Resolve-Path -LiteralPath $ShimDir).Path.TrimEnd('\', '/')
    }

    Get-Command $Name -CommandType Application -All -ErrorAction SilentlyContinue |
        Where-Object { -not $shim -or (Split-Path $_.Source -Parent).TrimEnd('\', '/') -ne $shim } |
        Select-Object -First 1 -ExpandProperty Source
}

function Find-CtxEditorInstall {
    <#
      An installation directory carrying this editor, found by layout.

      Looks for <root>/<something>/bin/<name>.cmd first -- the VS Code family
      signature, and the only shape where running the binary is safe -- then
      falls back to a bare <root>/<something>/<Name>.exe.
    #>

    param(
        [Parameter(Mandatory)][string]$Name,
        [string[]]$Roots = (Get-CtxEditorInstallRoots)
    )

    foreach ($root in $Roots) {
        $dirs = Get-ChildItem -LiteralPath $root -Directory -ErrorAction SilentlyContinue |
            Where-Object { $_.Name -replace '[\s_-]', '' -match ($Name -replace '[\s_-]', '') }

        foreach ($d in $dirs) {
            foreach ($candidate in @(
                    (Join-Path $d.FullName "bin/$Name.cmd"),
                    (Join-Path $d.FullName "resources/app/bin/$Name.cmd"),
                    (Join-Path $d.FullName "bin/$Name"))) {
                if (Test-Path -LiteralPath $candidate -PathType Leaf) {
                    return [pscustomobject]@{ Cli = $candidate; Exe = $null; Root = $d.FullName }
                }
            }

            $exe = Get-ChildItem -LiteralPath $d.FullName -Filter '*.exe' -File -ErrorAction SilentlyContinue |
                Where-Object { $_.BaseName -replace '[\s_-]', '' -eq ($Name -replace '[\s_-]', '') } |
                Select-Object -First 1
            if ($exe) {
                return [pscustomobject]@{ Cli = $null; Exe = $exe.FullName; Root = $d.FullName }
            }
        }
    }
}

function Find-CtxEditorExecutable {
    <#
      The GUI executable behind a command-line launcher.

      Needed because launching detached goes through `start <exe>`, never
      through the .cmd -- the .cmd holds the calling console open for the whole
      editing session, which is fine in a terminal and wrong for a shortcut.

      Walks UP from the launcher rather than assuming a depth. VS Code puts
      bin/code.cmd two levels under Code.exe; Cursor puts
      resources/app/bin/cursor.cmd four levels under Cursor.exe. A fixed
      "two levels up" is right for exactly one editor and quietly wrong for the
      next -- which is how a table becomes a bug.
    #>

    param(
        [Parameter(Mandatory)]$Editor,
        [int]$MaxDepth = 5
    )

    if ($Editor.Exe -and (Test-Path -LiteralPath $Editor.Exe)) { return $Editor.Exe }
    if (-not $Editor.Cli) { return }

    $cible = ($Editor.Name -replace '[\s_-]', '')
    $dossier = Split-Path $Editor.Cli -Parent

    for ($i = 0; $i -lt $MaxDepth -and $dossier; $i++) {
        $exe = Get-ChildItem -LiteralPath $dossier -Filter '*.exe' -File -ErrorAction SilentlyContinue |
            Where-Object { ($_.BaseName -replace '[\s_-]', '') -eq $cible } |
            Select-Object -First 1
        if ($exe) { return $exe.FullName }
        $parent = Split-Path $dossier -Parent
        if ($parent -eq $dossier) { break }
        $dossier = $parent
    }
}

function Get-CtxEditorFacts {
    <#
      Every editor found on this machine, with how it was found.

      Runs nothing. The verdict on what each one supports is a separate step,
      because gathering that opens windows is not gathering.
    #>

    param([string]$ContextRoot = $script:CtxRoot)

    foreach ($hint in Get-CtxEditorHints -ContextRoot $ContextRoot) {
        $cli = $null
        $exe = $null
        $root = $null

        $declared = Get-CtxProp $hint 'Command'
        if ($declared -and (Test-Path -LiteralPath $declared)) {
            $cli = $declared
            $root = Split-Path $declared -Parent
        }
        else {
            $cli = Find-CtxEditorCli -Name $hint.Name
            if ($cli) { $root = Split-Path (Split-Path $cli -Parent) -Parent }
            else {
                $found = Find-CtxEditorInstall -Name $hint.Name
                if ($found) { $cli = $found.Cli; $exe = $found.Exe; $root = $found.Root }
            }
        }

        if (-not $cli -and -not $exe) { continue }

        [pscustomobject]@{
            PSTypeName = 'DevContext.Editor'
            Name       = $hint.Name
            Label      = $hint.Label
            Profile    = $hint.Profile
            Cli        = $cli
            Exe        = $exe
            Root       = $root
        }
    }
}

# ---------------------------------------------------------------------------
# Capabilities -- measured when it is safe, read when it is not
# ---------------------------------------------------------------------------

function Test-CtxEditorProbeResult {
    <#
      PURE. Turns a probe's observations into a capability verdict.

      Split out so the interesting half is testable without an editor
      installed, and so the rule -- a flag counts as supported only when the
      directory it names actually appeared -- is stated in one place.

      Exit code alone proves nothing. Every editor in this family accepts an
      unknown flag and exits 0; Antigravity did precisely that with
      --extensions-dir. Only the side effect on disk is evidence.
    #>

    param(
        [int]$ExitCode = 0,
        [bool]$ProfileCreated = $false,
        [bool]$ExtensionsCreated = $false
    )

    [pscustomobject]@{
        UserDataDir   = $ProfileCreated
        ExtensionsDir = $ExtensionsCreated
        Method        = 'measured'
        ExitCode      = $ExitCode
    }
}

function Test-CtxEditorDeclaredFlags {
    <#
      PURE. What an application's own files say it accepts.

      Second best, and labelled as such. A flag present in the binary is
      evidence it is parsed; it is not evidence it does what we want with it.
    #>

    param([AllowNull()][AllowEmptyString()][string]$Content)

    [pscustomobject]@{
        UserDataDir   = [bool]($Content -and $Content.Contains('user-data-dir'))
        ExtensionsDir = [bool]($Content -and $Content.Contains('extensions-dir'))
        Method        = 'declared'
        ExitCode      = $null
    }
}

function Read-CtxEditorArgvSurface {
    <#
      The literal strings of an application bundle, for a flag lookup.

      An .asar is a concatenation with a JSON header -- reading it as text is
      crude and entirely sufficient to answer "does this string occur". Capped,
      because some bundles are hundreds of megabytes and this runs inside a
      diagnostic somebody is waiting on.
    #>

    param(
        [Parameter(Mandatory)][string]$Root,
        [int]$MaxBytes = 64MB
    )

    $candidates = @(
        Join-Path $Root 'resources/app.asar'
        Join-Path $Root 'resources/app/out/vs/code/node/cli.js'
        Join-Path $Root 'resources/app/package.json'
    )

    foreach ($c in $candidates) {
        if (-not (Test-Path -LiteralPath $c -PathType Leaf)) { continue }
        try {
            $info = Get-Item -LiteralPath $c
            if ($info.Length -gt $MaxBytes) { continue }
            return [System.Text.Encoding]::UTF8.GetString([System.IO.File]::ReadAllBytes($c))
        }
        catch { continue }
    }
}

function Test-CtxEditorCapabilities {
    <#
      What this editor lets us isolate -- measured if that is safe, read if not.

      SAFE means: a bin/<name>.cmd exists. That launcher is the VS Code family's
      command-line front end; it parses arguments, does its work and exits. An
      .exe with no such launcher is a GUI, and running one to ask it a question
      opens a window on someone's screen -- see the header of this file for the
      afternoon that established the rule.
    #>

    param(
        [Parameter(Mandatory)]$Editor,
        [string]$ScratchRoot = ([System.IO.Path]::GetTempPath())
    )

    if ($Editor.Cli) {
        $stem = Join-Path $ScratchRoot ('devctx-probe-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
        $profileDir = "$stem-profile"
        $extDir = "$stem-ext"
        try {
            $null = & $Editor.Cli --user-data-dir $profileDir --extensions-dir $extDir --list-extensions 2>&1
            $code = $LASTEXITCODE
            $result = Test-CtxEditorProbeResult -ExitCode $code `
                -ProfileCreated (Test-Path -LiteralPath $profileDir) `
                -ExtensionsCreated (Test-Path -LiteralPath $extDir)
        }
        catch {
            $result = [pscustomobject]@{
                UserDataDir = $false; ExtensionsDir = $false; Method = 'failed'; ExitCode = $null
            }
        }
        finally {
            foreach ($d in $profileDir, $extDir) {
                if (Test-Path -LiteralPath $d) { Remove-Item -LiteralPath $d -Recurse -Force -ErrorAction SilentlyContinue }
            }
        }
        return $result
    }

    $surface = if ($Editor.Root) { Read-CtxEditorArgvSurface -Root $Editor.Root }
    Test-CtxEditorDeclaredFlags -Content $surface
}

# ---------------------------------------------------------------------------
# The decision -- pure, and the heart of the shim
# ---------------------------------------------------------------------------

function Resolve-CtxEditorArguments {
    <#
      PURE. The command line to run, given what the editor supports.

      Injects only flags the probe established. Passing --extensions-dir to an
      editor that ignores it is not harmless: it reads as isolation to whoever
      wrote the shortcut, while the extensions stay shared.

      Order matters. Flags go BEFORE the caller's arguments, because a path
      argument ends the option list for some launchers. The caller's own
      arguments are never rewritten, reordered or deduplicated: --wait, --diff,
      -g file:line and --goto must arrive exactly as typed.

      A caller who passed --user-data-dir themselves wins. They said something
      explicit about where the profile goes, and second-guessing that would make
      this wrapper the thing that breaks a deliberate command.
    #>

    param(
        [Parameter(Mandatory)]$Capabilities,
        [Parameter(Mandatory)][string]$ContextDir,
        [Parameter(Mandatory)][string]$ProfileName,
        [string[]]$Arguments = @()
    )

    $injected = @()
    $already = @($Arguments | Where-Object { $_ } | ForEach-Object { "$_".Split('=')[0].ToLowerInvariant() })

    # [IO.Path]::Combine et NON Join-Path.
    #
    # Join-Path est un cmdlet de FOURNISSEUR : il resout le lecteur, et leve
    # « Cannot find drive » quand celui-ci n'est pas monte. Dans une fonction
    # pure sans -ErrorAction Stop, cela ne leve pas -- cela rend une chaine
    # VIDE, et la commande partait avec « --user-data-dir --extensions-dir . »,
    # ou le flag suivant est lu comme la valeur du precedent. Silencieux, donc.
    #
    # Mesure par la CI le 15 aout 2026 : les tests passaient ici, ou F: existe,
    # et echouaient sur un agent ou il n'existe pas. Exactement la classe de
    # defaut qui n'apparait que chez quelqu'un d'autre.
    if ((Get-CtxProp $Capabilities 'UserDataDir') -and '--user-data-dir' -notin $already) {
        $injected += '--user-data-dir', [System.IO.Path]::Combine($ContextDir, $ProfileName)
    }
    if ((Get-CtxProp $Capabilities 'ExtensionsDir') -and '--extensions-dir' -notin $already) {
        $injected += '--extensions-dir', [System.IO.Path]::Combine($ContextDir, $ProfileName + '-ext')
    }

    @($injected) + @($Arguments)
}

function Resolve-CtxEditorTargetPath {
    <#
      PURE. Which folder decides the context, given an editor's arguments.

      `code .`, `code F:\projects\thing`, `code src/main.ts`, `code --wait
      COMMIT_EDITMSG`, `code --diff a b`, `code -g file.ts:42` -- the context
      comes from the FILE BEING OPENED, not from where the terminal happens to
      sit. Opening a client project from a personal folder must load the client
      identity, or the wrapper reproduces the bug it exists to fix.

      Values belonging to a preceding flag are skipped: in `--log trace src/`
      the target is src/, not trace. Anything unrecognised falls back to the
      working directory, which is the behaviour before this file existed.

      -Classify is the ONLY contact with the filesystem, and it answers
      'file' / 'directory' / 'absent' in one call. An earlier version injected
      an existence test but kept its own Test-Path -PathType Leaf: a test could
      then describe a path that exists, and the function would still ask the
      real disk whether it was a file. Half-injected is not injected.
    #>

    param(
        [string[]]$Arguments = @(),
        [Parameter(Mandatory)][string]$WorkingDirectory,
        [scriptblock]$Classify = {
            param($p)
            if (-not (Test-Path -LiteralPath $p)) { return 'absent' }
            if (Test-Path -LiteralPath $p -PathType Leaf) { return 'file' }
            'directory'
        }
    )

    # Flags of the VS Code family that consume the next argument. An unknown
    # flag is assumed NOT to, because treating a path as a flag's value loses
    # the target entirely, while the reverse merely costs one extra candidate.
    $consumers = @(
        '--user-data-dir', '--extensions-dir', '--extension-development-path',
        '--extensions-download-dir', '--install-extension', '--uninstall-extension',
        '--locale', '--log', '--sync', '--profile', '--remote', '--folder-uri',
        '--file-uri', '--prof-startup-prefix', '--crash-reporter-directory'
    )

    $skipNext = $false
    foreach ($raw in @($Arguments)) {
        $arg = "$raw"
        if ($skipNext) { $skipNext = $false; continue }
        if ($arg.StartsWith('-')) {
            if ($arg -notmatch '=' -and $arg.ToLowerInvariant() -in $consumers) { $skipNext = $true }
            continue
        }

        # -g and --goto append :line:column, which is not part of the path.
        $candidate = $arg -replace '(?<=.):\d+(:\d+)?$', ''
        if (-not $candidate) { continue }

        if (-not [System.IO.Path]::IsPathRooted($candidate)) {
            # Combine, pas Join-Path : voir Resolve-CtxEditorArguments.
            $candidate = [System.IO.Path]::Combine($WorkingDirectory, $candidate)
        }

        # if/else and not switch: `continue` inside a switch continues the
        # ENCLOSING loop in PowerShell, which is right here and wrong the day
        # somebody moves this block. Not worth the reader's doubt.
        $kind = & $Classify $candidate
        # A file names its folder; the folder is what owns an identity.
        if ($kind -eq 'file')      { return Get-CtxNormalizedPath (Split-Path $candidate -Parent) }
        if ($kind -eq 'directory') { return Get-CtxNormalizedPath $candidate }
    }

    $WorkingDirectory
}

function Get-CtxNormalizedPath {
    <#
      PURE. Collapses '.' and '..' without asking the disk.

      `code .` yields 'F:\projet\.', which every prefix comparison in this
      module then has to cope with. GetFullPath does the collapsing and is
      purely textual -- Resolve-Path would fail on a path that does not exist,
      and a folder that does not exist is a case we still want an answer for.
    #>

    param([Parameter(Mandatory)][string]$Chemin)
    try { [System.IO.Path]::GetFullPath($Chemin) } catch { $Chemin }
}

# ---------------------------------------------------------------------------
# Cache -- so the probe runs once, not on every launch
# ---------------------------------------------------------------------------
#
# Keyed on the executable's path AND its last write time. An editor that
# updates itself gets re-probed; one that has not changed does not pay for a
# process launch every time somebody types `code .`.

function Get-CtxEditorCachePath {
    param([string]$ContextRoot = $script:CtxRoot)
    [System.IO.Path]::Combine($ContextRoot, 'editors.cache.json')
}

function Get-CtxEditorCacheKey {
    param([Parameter(Mandatory)]$Editor)
    $target = if ($Editor.Cli) { $Editor.Cli } else { $Editor.Exe }
    $stamp = ''
    if ($target -and (Test-Path -LiteralPath $target)) {
        $stamp = (Get-Item -LiteralPath $target).LastWriteTimeUtc.ToString('o')
    }
    '{0}|{1}|{2}' -f $Editor.Name, $target, $stamp
}

function Get-CtxEditorCapabilitiesCached {
    param(
        [Parameter(Mandatory)]$Editor,
        [string]$ContextRoot = $script:CtxRoot,
        [switch]$Refresh
    )

    $path = Get-CtxEditorCachePath -ContextRoot $ContextRoot
    $key = Get-CtxEditorCacheKey -Editor $Editor
    $cache = @{}

    if (-not $Refresh -and (Test-Path -LiteralPath $path)) {
        try {
            $raw = Get-Content -LiteralPath $path -Raw -ErrorAction Stop | ConvertFrom-Json -AsHashtable -ErrorAction Stop
            if ($raw -is [System.Collections.IDictionary]) { $cache = $raw }
        }
        catch { $cache = @{} }

        if ($cache.ContainsKey($key)) {
            $hit = $cache[$key]
            return [pscustomobject]@{
                UserDataDir   = [bool]$hit['UserDataDir']
                ExtensionsDir = [bool]$hit['ExtensionsDir']
                Method        = [string]$hit['Method']
                ExitCode      = $hit['ExitCode']
            }
        }
    }

    $caps = Test-CtxEditorCapabilities -Editor $Editor

    try {
        if (-not (Test-Path -LiteralPath $ContextRoot)) {
            New-Item -ItemType Directory -Path $ContextRoot -Force -ErrorAction Stop | Out-Null
        }
        $cache[$key] = @{
            UserDataDir = $caps.UserDataDir; ExtensionsDir = $caps.ExtensionsDir
            Method      = $caps.Method;      ExitCode      = $caps.ExitCode
        }
        $cache | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $path -Encoding UTF8 -ErrorAction Stop
    }
    catch {
        # A cache that cannot be written costs a probe next time. Nothing more.
        Write-Verbose "cache editeurs non ecrit : $($_.Exception.Message)"
    }

    $caps
}

# ---------------------------------------------------------------------------
# Public surface
# ---------------------------------------------------------------------------

function Get-DevEditorList {
    <#
    .SYNOPSIS
        Editors found on this machine, and whether each can be isolated.

    .DESCRIPTION
        Answers the question a shortcut cannot: if I open this project with
        this editor, does it get its own sign-ins, or does it share yours?

        Isolation is measured where a command-line entry point exists, and read
        from the application's own files where it does not. The two are
        reported differently on purpose -- see Methode.

    .PARAMETER Refresh
        Ignore the cache and probe again. Use after installing or updating an
        editor, though a changed timestamp already invalidates its entry.

    .EXAMPLE
        ctx-editors
    #>

    [CmdletBinding()]
    param([switch]$Refresh)

    foreach ($editor in Get-CtxEditorFacts) {
        $caps = Get-CtxEditorCapabilitiesCached -Editor $editor -Refresh:$Refresh
        # Isole et ExtensionsIsolees sont les champs sur lesquels le CODE decide.
        # Profil et Extensions sont ceux que l'HUMAIN lit, et ils sont traduits.
        #
        # Les deux existent parce que confondre les deux est un bug reel : le
        # doctor comparait `$e.Profil -eq 'isole'`, un litteral francais. Des que
        # la sortie est passee en anglais, la comparaison a echoue et CHAQUE
        # editeur a ete rapporte comme non isole -- un faux avertissement, en
        # anglais seulement, donc invisible sur la machine qui l'a introduit.
        # Une valeur affichee ne doit jamais servir de valeur de decision.
        [pscustomobject]@{
            PSTypeName        = 'DevContext.EditorStatus'
            Editeur           = $editor.Label
            Commande          = $editor.Name
            Isole             = [bool]$caps.UserDataDir
            ExtensionsIsolees = [bool]$caps.ExtensionsDir
            Profil     = if ($caps.UserDataDir) { T 'editeur.profil.isole' } else { T 'editeur.profil.partage' }
            Extensions = if ($caps.ExtensionsDir) { T 'editeur.ext.isolees' } else { T 'editeur.ext.partagees' }
            Methode    = $caps.Method
            Chemin     = if ($editor.Cli) { $editor.Cli } else { $editor.Exe }
        }
    }
}