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', '--shared-data-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.

      Excluded through Test-CtxDossierEstShimDevContext, never against a single
      path. Comparing one name is what broke every desktop shortcut on
      16 Aug 2026: PATH named the junction, this function named the module, and
      the two strings differ though the folder is the same one. Open-DevCode then
      took our own shim for the editor CLI, found no Code.exe above it, and fell
      back to its SYNCHRONOUS branch -- so the launcher window stayed open for
      the whole editing session.
    #>

    param(
        [Parameter(Mandatory)][string]$Name,
        [string[]]$Dossiers = (Get-CtxShimDirs)
    )

    Get-Command $Name -CommandType Application -All -ErrorAction SilentlyContinue |
        Where-Object {
            -not (Test-CtxDossierEstShimDevContext -Dossier (Split-Path $_.Source -Parent) -Dossiers $Dossiers)
        } |
        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,
        [AllowNull()]$Declared = $null,
        [AllowNull()][AllowEmptyString()][string]$SharedDataStore = $null
    )

    # --shared-data-dir NE PEUT PAS etre mesure ici, et c'est une mesure, pas
    # une hypothese : le 19 aout 2026, `code --user-data-dir X --extensions-dir
    # Y --shared-data-dir Z --list-extensions` sort 0, cree X et Y, et ne cree
    # PAS Z. Le chemin ligne de commande n'initialise jamais le magasin
    # partage ; seul le processus graphique le fait, ce qu'une sonde n'a pas le
    # droit d'ouvrir.
    #
    # La regle « seul l'effet sur le disque fait preuve » vaut toujours pour ce
    # qu'elle couvre. Ici elle ne couvre rien : l'ABSENCE d'effet ne prouve pas
    # l'absence de support. Repondre $false serait donc affirmer plus que ce
    # qu'on sait -- et le repondre avec l'autorite d'une mesure.
    #
    # Ce champ vient donc de la surface d'arguments declaree, et il est
    # etiquete comme tel. Le verdict reel se prend ailleurs : `ctx doctor`
    # regarde si le magasin du contexte existe sur le disque, ce qui est une
    # mesure de l'usage reel et non du support suppose.
    [pscustomobject]@{
        UserDataDir      = $ProfileCreated
        ExtensionsDir    = $ExtensionsCreated
        SharedDataDir    = [bool](Get-CtxProp $Declared 'SharedDataDir')
        SharedDataStore  = $SharedDataStore
        Method           = 'measured'
        SharedDataMethod = if ($null -eq $Declared) { 'unknown' } else { 'declared' }
        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'))
        SharedDataDir    = [bool]($Content -and $Content.Contains('shared-data-dir'))
        Method           = 'declared'
        SharedDataMethod = '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
    )

    # LE DOSSIER DE VERSION, mesure le 19 aout 2026.
    #
    # VS Code n'installe plus son application sous <racine>\resources : il
    # intercale un dossier portant l'identifiant de la build --
    # ...\Microsoft VS Code\a5b5009513\resources\app. Les trois chemins
    # d'origine ne designaient donc plus rien, et cette fonction rendait $null
    # SANS RIEN DIRE. L'appelant lisait « cet editeur ne declare aucun flag »,
    # ce qui est le contraire de la verite.
    #
    # On essaie donc la racine, PUIS chaque dossier de premier niveau. Un seul
    # niveau, volontairement : une recherche profonde dans une installation
    # d'editeur coute des minutes, et un diagnostic que personne n'attend est
    # un diagnostic que personne ne lance.
    $bases = @($Root)
    try {
        $sousDossiers = Get-ChildItem -LiteralPath $Root -Directory -ErrorAction Stop
        $bases += @($sousDossiers | Select-Object -ExpandProperty FullName)
    }
    catch {
        # Racine illisible : on garde la racine seule plutot que d'echouer.
        $null = $_
    }

    # Du plus parlant au moins parlant, et NON l'inverse. package.json existe
    # dans presque toutes les dispositions et ne contient aucun flag : le
    # rencontrer en premier rendrait une reponse valide et vide.
    $relatifs = @(
        'resources/app.asar'
        'resources/app/out/cli.js'
        'resources/app/out/main.js'
        'resources/app/out/vs/code/node/cli.js'
        'resources/app/package.json'
    )

    foreach ($rel in $relatifs) {
        foreach ($base in $bases) {
            $c = [System.IO.Path]::Combine($base, $rel)
            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 Get-CtxEditorMagasinPartage {
    <#
      Le nom du dossier de stockage COMMUN A LA MACHINE que cet editeur
      utilise, ou $null s'il n'en a pas.

      POURQUOI CETTE QUESTION EXISTE SEPAREMENT DE LA CAPACITE

      "L'editeur accepte --shared-data-dir" et "l'editeur possede un magasin
      commun" sont deux questions differentes, et les confondre fabrique un
      faux positif.

      Mesure le 19 aout 2026 sur cette machine : product.json declare
      sharedDataFolderName = '.vscode-shared' pour VS Code, et ne le declare
      PAS pour Cursor, Windsurf ni Trae. Ces forks sont partis d'un VS Code
      anterieur a la fonctionnalite : ils n'ont pas de magasin commun, donc
      rien a isoler, et leur --user-data-dir couvre encore tout. Les avertir
      serait un verdict rouge sur une machine parfaitement rangee.

      Rend $null quand on ne peut pas savoir -- editeur sans dossier
      d'installation connu, product.json illisible. Le silence est le bon repli
      ici : on ne perd qu'un avertissement, et on n'en invente aucun.
    #>

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

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

    $bases = @($Root)
    try {
        $sous = Get-ChildItem -LiteralPath $Root -Directory -ErrorAction Stop
        $bases += @($sous | Select-Object -ExpandProperty FullName)
    }
    catch { $null = $_ }

    foreach ($base in $bases) {
        # [IO.Path]::Combine et NON Join-Path : cmdlet de FOURNISSEUR, il
        # resout le lecteur et echoue sur 'Cannot find drive' quand il n'est
        # pas monte. Le piege est documente dans AGENTS.md et il a quand meme
        # ete refait ici le 19 aout 2026 -- attrape par le test qui donne
        # expres une racine sur Q:.
        $p = [System.IO.Path]::Combine($base, 'resources', 'app', 'product.json')
        if (-not (Test-Path -LiteralPath $p -PathType Leaf)) { continue }
        try {
            $produit = Get-Content -LiteralPath $p -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
            return (Get-CtxProp $produit 'sharedDataFolderName')
        }
        catch { continue }
    }
    $null
}

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) {
        # La surface declaree est lue MEME quand la sonde peut tourner, parce
        # que la sonde est aveugle a --shared-data-dir (voir
        # Test-CtxEditorProbeResult). Le cout est un fichier lu une fois par
        # version d'editeur : le resultat est mis en cache.
        $declare = $null
        if (Get-CtxProp $Editor 'Root') {
            $surfaceCli = Read-CtxEditorArgvSurface -Root $Editor.Root
            if ($surfaceCli) { $declare = Test-CtxEditorDeclaredFlags -Content $surfaceCli }
        }
        $magasin = Get-CtxEditorMagasinPartage -Root (Get-CtxProp $Editor 'Root')

        $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) `
                -Declared $declare -SharedDataStore $magasin
        }
        catch {
            $result = [pscustomobject]@{
                UserDataDir = $false; ExtensionsDir = $false; SharedDataDir = $false
                SharedDataStore = $magasin
                Method = 'failed'; SharedDataMethod = 'unknown'; 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 }
    $declareSeul = Test-CtxEditorDeclaredFlags -Content $surface
    $declareSeul | Add-Member -NotePropertyName SharedDataStore `
        -NotePropertyValue (Get-CtxEditorMagasinPartage -Root (Get-CtxProp $Editor 'Root')) -PassThru
}

# ---------------------------------------------------------------------------
# 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')
    }

    # LE MAGASIN PARTAGE. Sans ce flag, VS Code 1.133 et suivants ecrivent les
    # SECRETS des extensions, la liste des dossiers recents et celle des
    # dossiers approuves dans ~/.vscode-shared -- un seul fichier pour toute la
    # machine, quel que soit le --user-data-dir.
    #
    # Le chiffrement, lui, est reste par profil. Deux contextes ecrivent donc
    # la meme entree avec deux cles differentes, et le suivant ne sait plus la
    # lire : l'editeur jette l'entree et redemande une connexion. Le symptome
    # visible est cette boucle ; le cout reel est qu'un chemin de projet CLIENT
    # se retrouve dans les dossiers recents d'une fenetre PERSONNELLE.
    if ((Get-CtxProp $Capabilities 'SharedDataDir') -and '--shared-data-dir' -notin $already) {
        $injected += '--shared-data-dir', [System.IO.Path]::Combine($ContextDir, $ProfileName + '-shared')
    }

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

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

      `code .`, `code C:\work\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', '--shared-data-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 .`.

# Version de l'enregistrement mis en cache. Voir Get-CtxEditorCacheKey.
$script:EditorCacheSchema = 3

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')
    }

    # LE NUMERO DE SCHEMA, et pourquoi il est dans la CLE.
    #
    # Le cache est relu en table de hachage. Y lire une cle absente ne leve
    # pas : cela rend $null, que [bool] transforme en $false. Une entree
    # ecrite avant l'ajout d'un champ repondrait donc « non supporte » avec
    # l'autorite d'une mesure -- et pour toujours, puisque ni le chemin ni
    # l'horodatage de l'editeur n'auraient change.
    #
    # Le prix d'une incrementation est une sonde de plus, une seule fois, par
    # editeur. A INCREMENTER des qu'un champ est ajoute a l'enregistrement.
    '{0}|{1}|{2}|{3}' -f $script:EditorCacheSchema, $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']
                SharedDataDir    = [bool]$hit['SharedDataDir']
                SharedDataStore  = $hit['SharedDataStore']
                Method           = [string]$hit['Method']
                SharedDataMethod = [string]$hit['SharedDataMethod']
                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
            SharedDataDir    = [bool](Get-CtxProp $caps 'SharedDataDir')
            SharedDataStore  = Get-CtxProp $caps 'SharedDataStore'
            Method           = $caps.Method
            SharedDataMethod = [string](Get-CtxProp $caps 'SharedDataMethod' 'unknown')
            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
            PartageIsole      = [bool](Get-CtxProp $caps 'SharedDataDir')
            PartageConcerne   = [bool](Get-CtxProp $caps 'SharedDataStore')
            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' }
            # Trois etats, pas deux. Un editeur SANS magasin commun n'a rien a
            # isoler : lui coller 'COMMUN' inventerait un probleme. Mesure du
            # 19 aout 2026 : Cursor, Windsurf et Trae ne declarent aucun
            # sharedDataFolderName.
            Partage    = if (-not (Get-CtxProp $caps 'SharedDataStore')) { T 'editeur.partage.sansObjet' }
            elseif (Get-CtxProp $caps 'SharedDataDir') { T 'editeur.partage.isole' }
            else { T 'editeur.partage.commun' }
            Methode    = $caps.Method
            Chemin     = if ($editor.Cli) { $editor.Cli } else { $editor.Exe }
        }
    }
}