HJ_PC 上 code-review / codex_app 以 cmd.exe 启动、cwd 在 plugins\cache\openai-bundled, 旧逻辑只看 command 识别不出,-Apply 把它们复制进了 Claude; powershell -File 传 -ExcludeName a,b 时绑定成单个字符串 a,b,排除也失效。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
432 lines
19 KiB
PowerShell
432 lines
19 KiB
PowerShell
# tools-sync: audit and reconcile user-configured MCP registrations between
|
|
# Claude Code and Codex. Managed connectors, plugins, and Codex runtime MCPs are
|
|
# intentionally excluded because they are owned by their host application.
|
|
#
|
|
# Codex storage model (measured on codex-cli 0.150.1, not documented anywhere):
|
|
# * `codex mcp list --json` / `add` / `remove` are the ONLY working interface.
|
|
# * config.toml's [mcp_servers.*] tables are INERT. Editing node_repl's startup_timeout_sec
|
|
# there from 120 to 137 left `codex mcp list` reporting 120, and a section written by hand
|
|
# never appeared at all. Hand-writing TOML sections -- what this skill used to instruct --
|
|
# silently does nothing. The sections still in config.toml are legacy leftovers.
|
|
# * `codex mcp add` persists outside config.toml and has no flag for tool_timeout_sec or
|
|
# env_vars, so those keys currently have no setting entry point at all.
|
|
# * That store is not durable across CLI upgrades: seven user MCPs registered on 2026-08-29
|
|
# were gone after the upgrade to 0.150.1. Re-audit Codex MCPs on every sync; ~/skills_env.md
|
|
# is the durable record to re-register from.
|
|
#
|
|
# Run from PowerShell, NOT git-bash: MSYS rewrites `/c`-style args and Windows paths.
|
|
|
|
[CmdletBinding()]
|
|
param(
|
|
[string]$ClaudeConfig = "$env:USERPROFILE\.claude.json",
|
|
[string]$CodexConfig = "$env:USERPROFILE\.codex\config.toml",
|
|
[switch]$Apply,
|
|
[ValidateSet("None", "Claude", "Codex")]
|
|
[string]$ConflictAuthority = "None",
|
|
[string[]]$ExcludeName = @(),
|
|
[string]$PythonCommand = "python",
|
|
# Where to read Codex's registrations. Auto = `codex mcp list --json`, falling back to
|
|
# parsing -CodexConfig when the CLI is unavailable (older codex, or a test fixture).
|
|
# Toml forces the fixture path so tests can isolate from the real machine.
|
|
[ValidateSet("Auto", "Cli", "Toml")]
|
|
[string]$CodexSource = "Auto",
|
|
# Codex-side keys some MCPs need that codex-cli offers no way to set (see the note above).
|
|
# Resolved after the param block: $PSScriptRoot is not yet bound in default expressions here.
|
|
[string]$OverridesFile
|
|
)
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
# `powershell -File x.ps1 -ExcludeName a,b` binds the single string "a,b"; split it ourselves.
|
|
$ExcludeName = @($ExcludeName | ForEach-Object { $_ -split ',' } | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
|
$script:CodexBackupMade = $false
|
|
$script:Failures = @()
|
|
$script:Unsupported = @()
|
|
$CodexOnlyEnv = @("PYTHONIOENCODING", "PYTHONUTF8")
|
|
|
|
function Test-TomllibPython([string]$Candidate) {
|
|
# tomllib needs CPython 3.11+; the Microsoft Store 'python' stub exits 49 and has no stdlib.
|
|
if (-not $Candidate) { return $false }
|
|
try { & $Candidate -c "import tomllib" 2>&1 | Out-Null } catch { return $false }
|
|
return ($LASTEXITCODE -eq 0)
|
|
}
|
|
|
|
function Resolve-PythonCommand([string]$Requested, [bool]$Explicit) {
|
|
# An explicit -PythonCommand is authoritative; only auto-probe the default.
|
|
if ($Explicit) { return $Requested }
|
|
if (Test-TomllibPython $Requested) { return $Requested }
|
|
$envFile = "$env:USERPROFILE\skills_env.md"
|
|
if (Test-Path -LiteralPath $envFile) {
|
|
$line = Select-String -LiteralPath $envFile -Pattern '^\s*CONDA_LANGGRAPH_PYTHON\s*=\s*(.+?)\s*$' |
|
|
Select-Object -First 1
|
|
if ($line) {
|
|
$cand = $line.Matches[0].Groups[1].Value.Trim()
|
|
if ((Test-Path -LiteralPath $cand) -and (Test-TomllibPython $cand)) {
|
|
Write-Host "python: falling back to CONDA_LANGGRAPH_PYTHON ($cand)"
|
|
return $cand
|
|
}
|
|
}
|
|
}
|
|
# Single-token candidates only: '& $cmd' treats the whole string as one exe name.
|
|
foreach ($cand in @("python3", "py")) {
|
|
if ((Get-Command $cand -ErrorAction SilentlyContinue) -and (Test-TomllibPython $cand)) {
|
|
Write-Host "python: falling back to '$cand'"
|
|
return $cand
|
|
}
|
|
}
|
|
throw "No tomllib-capable Python found ('$Requested' unusable). Pass -PythonCommand <path to CPython 3.11+>."
|
|
}
|
|
|
|
$PythonCommand = Resolve-PythonCommand $PythonCommand $PSBoundParameters.ContainsKey('PythonCommand')
|
|
|
|
if (-not $OverridesFile) {
|
|
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
$OverridesFile = Join-Path (Split-Path -Parent $here) "references\codex_mcp_wanted_keys.json"
|
|
}
|
|
|
|
$script:Overrides = @{}
|
|
if (Test-Path -LiteralPath $OverridesFile) {
|
|
$raw = Get-Content -Raw -LiteralPath $OverridesFile | ConvertFrom-Json
|
|
foreach ($property in $raw.psobject.Properties) {
|
|
if ($property.Name -notlike "_*") { $script:Overrides[$property.Name] = $property.Value }
|
|
}
|
|
}
|
|
|
|
function ConvertTo-StringMap($Object) {
|
|
$result = [ordered]@{}
|
|
if ($null -eq $Object) { return $result }
|
|
foreach ($property in $Object.psobject.Properties | Sort-Object Name) {
|
|
$result[$property.Name] = [string]$property.Value
|
|
}
|
|
return $result
|
|
}
|
|
|
|
function ConvertFrom-DictionaryToStringMap($Dictionary) {
|
|
$result = [ordered]@{}
|
|
if ($null -eq $Dictionary) { return $result }
|
|
foreach ($key in @($Dictionary.Keys) | Sort-Object) {
|
|
$result[[string]$key] = [string]$Dictionary[$key]
|
|
}
|
|
return $result
|
|
}
|
|
|
|
function New-CanonicalMcp([string]$Name, [string]$Transport, [string]$Command,
|
|
[object[]]$CommandArgs, $EnvMap, [string]$Url,
|
|
$Headers, [string]$BearerTokenEnvVar, [string]$Source) {
|
|
[pscustomobject][ordered]@{
|
|
name = $Name
|
|
transport = $Transport
|
|
command = if ($Command) { $Command } else { "" }
|
|
args = @($CommandArgs | ForEach-Object { [string]$_ })
|
|
env = $EnvMap
|
|
url = if ($Url) { $Url } else { "" }
|
|
headers = $Headers
|
|
bearer_token_env_var = if ($BearerTokenEnvVar) { $BearerTokenEnvVar } else { "" }
|
|
source = $Source
|
|
}
|
|
}
|
|
|
|
function Get-ClaudeMcps {
|
|
if (-not (Test-Path -LiteralPath $ClaudeConfig)) {
|
|
throw "Claude Code config not found: $ClaudeConfig"
|
|
}
|
|
$root = Get-Content -Raw -LiteralPath $ClaudeConfig | ConvertFrom-Json
|
|
$result = @{}
|
|
if ($null -eq $root.mcpServers) { return $result }
|
|
foreach ($property in $root.mcpServers.psobject.Properties) {
|
|
$name = $property.Name
|
|
$cfg = $property.Value
|
|
$transport = if ($cfg.type -in @("http", "sse")) { [string]$cfg.type } else { "stdio" }
|
|
$result[$name] = New-CanonicalMcp $name $transport ([string]$cfg.command) @($cfg.args) `
|
|
(ConvertTo-StringMap $cfg.env) ([string]$cfg.url) `
|
|
(ConvertTo-StringMap $cfg.headers) "" "Claude"
|
|
}
|
|
return $result
|
|
}
|
|
|
|
function Get-CodexMcpsFromCli {
|
|
# Codex >= 26.8 no longer persists user MCP registrations in config.toml;
|
|
# `codex mcp list --json` is the authoritative view. Returns $null when the
|
|
# CLI is unavailable so the caller can fall back to TOML parsing.
|
|
$codexExe = Get-Command codex -ErrorAction SilentlyContinue
|
|
if (-not $codexExe) { return $null }
|
|
$raw = @(& $codexExe.Source mcp list --json 2>$null)
|
|
if ($LASTEXITCODE -ne 0 -or -not $raw) { return $null }
|
|
try { $servers = ($raw -join "`n") | ConvertFrom-Json } catch { return $null }
|
|
if ($null -eq $servers) { return $null }
|
|
$result = @{}
|
|
foreach ($s in @($servers)) {
|
|
$t = $s.transport
|
|
$transport = if ($t.type) { [string]$t.type } elseif ($t.url) { "http" } else { "stdio" }
|
|
if ($transport -eq "streamable_http") { $transport = "http" }
|
|
$mcp = New-CanonicalMcp ([string]$s.name) $transport ([string]$t.command) `
|
|
@($t.args) (ConvertTo-StringMap $t.env) ([string]$t.url) `
|
|
(ConvertTo-StringMap $t.http_headers) ([string]$t.bearer_token_env_var) "Codex"
|
|
# Not part of the drift comparison; Test-CodexManaged needs it because bundled
|
|
# plugin MCPs launch as plain `cmd.exe` and only their cwd names the plugin cache.
|
|
$mcp | Add-Member -NotePropertyName cwd -NotePropertyValue ([string]$t.cwd)
|
|
$result[[string]$s.name] = $mcp
|
|
}
|
|
return $result
|
|
}
|
|
|
|
function Get-CodexMcps {
|
|
if ($CodexSource -ne "Toml") {
|
|
$fromCli = Get-CodexMcpsFromCli
|
|
if ($null -ne $fromCli) { return $fromCli }
|
|
if ($CodexSource -eq "Cli") { throw "codex mcp list --json unavailable and -CodexSource Cli forbids the TOML fallback" }
|
|
}
|
|
|
|
if (-not (Test-Path -LiteralPath $CodexConfig)) {
|
|
throw "Codex config not found: $CodexConfig"
|
|
}
|
|
$python = @'
|
|
import json, sys, tomllib
|
|
with open(sys.argv[1], "rb") as f:
|
|
data = tomllib.load(f)
|
|
print(json.dumps(data.get("mcp_servers", {}), ensure_ascii=False))
|
|
'@
|
|
$parseOutput = @($python | & $PythonCommand - $CodexConfig 2>&1)
|
|
if ($LASTEXITCODE -ne 0) {
|
|
$detail = ($parseOutput | ForEach-Object { "$_" }) -join "`n"
|
|
throw "Failed to parse Codex TOML with $PythonCommand`: $detail"
|
|
}
|
|
$json = $parseOutput -join "`n"
|
|
$root = $json | ConvertFrom-Json
|
|
$result = @{}
|
|
foreach ($property in $root.psobject.Properties) {
|
|
$name = $property.Name
|
|
$cfg = $property.Value
|
|
$transport = if ($cfg.url) { "http" } else { "stdio" }
|
|
$result[$name] = New-CanonicalMcp $name $transport ([string]$cfg.command) @($cfg.args) `
|
|
(ConvertTo-StringMap $cfg.env) ([string]$cfg.url) `
|
|
(ConvertTo-StringMap $cfg.http_headers) ([string]$cfg.bearer_token_env_var) "Codex"
|
|
}
|
|
return $result
|
|
}
|
|
|
|
function Test-CodexManaged($Mcp) {
|
|
if ($Mcp.name -in @("node_repl", "cua_repl")) { return $true }
|
|
$command = [string]$Mcp.command
|
|
if ($command -match '(?i)[\\/]\.codex[\\/](runtimes|plugins[\\/]cache)[\\/]') { return $true }
|
|
# codex-cli 0.162: code-review / codex_app are `cmd.exe /c scripts\launch_*.cmd` with
|
|
# cwd under .codex\plugins\cache\openai-bundled -- the command alone looks portable.
|
|
if ([string]$Mcp.cwd -match '(?i)[\\/]\.codex[\\/](runtimes|plugins[\\/]cache)[\\/]') { return $true }
|
|
# The plugin cache may also live outside ~\.codex (e.g. orca's codex-runtime-home). 2026-10-08 HJ_PC.
|
|
if ([string]$Mcp.cwd -match '(?i)[\\/]plugins[\\/]cache[\\/]') { return $true }
|
|
# The Codex desktop app also bundles MCPs from its own install dir (MSIX
|
|
# WindowsApps, or AppData\Local\OpenAI\Codex). Host-owned, not portable.
|
|
return ($command -match '(?i)[\\/](WindowsApps[\\/]OpenAI\.Codex|OpenAI[\\/]Codex[\\/])')
|
|
}
|
|
|
|
function Get-PortableMcps($Map, [string]$Side) {
|
|
$result = @{}
|
|
foreach ($name in $Map.Keys) {
|
|
if ($ExcludeName -contains $name) { continue }
|
|
$mcp = $Map[$name]
|
|
if ($Side -eq "Codex" -and (Test-CodexManaged $mcp)) { continue }
|
|
$result[$name] = $mcp
|
|
}
|
|
return $result
|
|
}
|
|
|
|
function ConvertTo-PortablePath([string]$Value) {
|
|
# `claude mcp add-json` rejects Windows backslash paths ("Invalid configuration:
|
|
# : Invalid input"); forward slashes work on Windows and are what the CLI itself
|
|
# writes. Only rewrite strings that actually carry a drive-letter path, so flags
|
|
# and non-path args are left alone (`--extension-file=C:\..` is rewritten too).
|
|
if ($null -eq $Value) { return $Value }
|
|
if ($Value -match '[A-Za-z]:\\') { return ($Value -replace '\\', '/') }
|
|
return $Value
|
|
}
|
|
|
|
function Get-ComparableJson($Mcp) {
|
|
$envMap = [ordered]@{}
|
|
foreach ($key in @($Mcp.env.Keys) | Sort-Object) {
|
|
if ($CodexOnlyEnv -contains $key) { continue }
|
|
$envMap[$key] = [string]$Mcp.env[$key]
|
|
}
|
|
$headers = [ordered]@{}
|
|
foreach ($key in @($Mcp.headers.Keys) | Sort-Object) {
|
|
$headers[$key] = [string]$Mcp.headers[$key]
|
|
}
|
|
# `C:\x\y` and `C:/x/y` are the same target on Windows -- the two CLIs write
|
|
# different separators, so compare on a single normalized form or every path
|
|
# arg reads as drift.
|
|
$value = [ordered]@{
|
|
transport = $Mcp.transport
|
|
command = (ConvertTo-PortablePath $Mcp.command)
|
|
args = @(@($Mcp.args) | ForEach-Object { ConvertTo-PortablePath $_ })
|
|
env = $envMap
|
|
url = $Mcp.url
|
|
headers = $headers
|
|
bearer_token_env_var = $Mcp.bearer_token_env_var
|
|
}
|
|
return ($value | ConvertTo-Json -Depth 8 -Compress)
|
|
}
|
|
|
|
function Backup-CodexConfig {
|
|
if ($script:CodexBackupMade) { return }
|
|
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
|
$backup = "$CodexConfig.bak-$stamp"
|
|
Copy-Item -LiteralPath $CodexConfig -Destination $backup
|
|
$script:CodexBackupMade = $true
|
|
Write-Host "backup: $backup"
|
|
}
|
|
|
|
function Invoke-External([string]$Label, [string]$Exe, [string[]]$Arguments) {
|
|
& $Exe @Arguments
|
|
if ($LASTEXITCODE -ne 0) { throw "$Label failed (exit $LASTEXITCODE)" }
|
|
}
|
|
|
|
function Add-ToCodex($Mcp) {
|
|
if ($Mcp.transport -eq "stdio") {
|
|
Backup-CodexConfig
|
|
$arguments = @("mcp", "add", $Mcp.name)
|
|
$envMap = [ordered]@{}
|
|
foreach ($key in $Mcp.env.Keys) { $envMap[$key] = $Mcp.env[$key] }
|
|
# Without these, Codex mangles non-ASCII stdio output on Windows.
|
|
$envMap["PYTHONIOENCODING"] = "utf-8"
|
|
$envMap["PYTHONUTF8"] = "1"
|
|
foreach ($key in $envMap.Keys) { $arguments += @("--env", "$key=$($envMap[$key])") }
|
|
$arguments += "--"
|
|
$arguments += $Mcp.command
|
|
$arguments += @($Mcp.args)
|
|
Invoke-External "codex add $($Mcp.name)" "codex" $arguments
|
|
Report-UnsupportedKeys $Mcp.name
|
|
return
|
|
}
|
|
if ($Mcp.headers.Count -gt 0) {
|
|
throw "HTTP headers cannot be translated safely by 'codex mcp add': $($Mcp.name)"
|
|
}
|
|
Backup-CodexConfig
|
|
Invoke-External "codex add $($Mcp.name)" "codex" @("mcp", "add", $Mcp.name, "--url", $Mcp.url)
|
|
Report-UnsupportedKeys $Mcp.name
|
|
}
|
|
|
|
# Some MCPs need Codex keys that `codex mcp add` has no flag for and that config.toml
|
|
# cannot supply either (see the storage-model note at the top). Surface them rather than
|
|
# pretending the registration is complete.
|
|
function Report-UnsupportedKeys([string]$Name) {
|
|
if (-not $script:Overrides.ContainsKey($Name)) { return }
|
|
$keys = @($script:Overrides[$Name].psobject.Properties | Where-Object { $_.Name -notlike "_*" } |
|
|
ForEach-Object { "$($_.Name)=$($_.Value)" })
|
|
if ($keys) { $script:Unsupported += "$Name wants $($keys -join ', ') - no interface in codex-cli to set these" }
|
|
}
|
|
|
|
function Add-ToClaude($Mcp) {
|
|
# Deliberately NOT `claude mcp add-json`: Windows PowerShell 5.1 strips the
|
|
# double quotes out of a JSON argument on its way to a native exe, so claude.exe
|
|
# sees malformed JSON and dies with "Invalid configuration: : Invalid input".
|
|
# The flag form takes plain tokens and is immune. (Separately, add-json rejects
|
|
# backslash paths even when quoting is clean -- hence ConvertTo-PortablePath.)
|
|
if ($Mcp.transport -eq "stdio") {
|
|
$arguments = @("mcp", "add", $Mcp.name, "--scope", "user")
|
|
foreach ($key in $Mcp.env.Keys) {
|
|
if ($CodexOnlyEnv -contains $key) { continue }
|
|
$arguments += @("--env", "$key=$($Mcp.env[$key])")
|
|
}
|
|
$arguments += "--"
|
|
$arguments += (ConvertTo-PortablePath $Mcp.command)
|
|
foreach ($arg in @($Mcp.args)) { $arguments += (ConvertTo-PortablePath $arg) }
|
|
Invoke-External "claude add $($Mcp.name)" "claude" $arguments
|
|
return
|
|
}
|
|
if ($Mcp.bearer_token_env_var) {
|
|
throw "Codex bearer_token_env_var has no lossless Claude translation: $($Mcp.name)"
|
|
}
|
|
$arguments = @("mcp", "add", $Mcp.name, "--scope", "user",
|
|
"--transport", $Mcp.transport, $Mcp.url)
|
|
foreach ($key in $Mcp.headers.Keys) {
|
|
$arguments += @("--header", "$key`: $($Mcp.headers[$key])")
|
|
}
|
|
Invoke-External "claude add $($Mcp.name)" "claude" $arguments
|
|
}
|
|
|
|
function Remove-FromClaude([string]$Name) {
|
|
Invoke-External "claude remove $Name" "claude" @("mcp", "remove", "--scope", "user", $Name)
|
|
}
|
|
|
|
function Remove-FromCodex([string]$Name) {
|
|
Backup-CodexConfig
|
|
Invoke-External "codex remove $Name" "codex" @("mcp", "remove", $Name)
|
|
}
|
|
|
|
function Try-Action([string]$Description, [scriptblock]$Action) {
|
|
try {
|
|
& $Action
|
|
Write-Host "OK: $Description"
|
|
} catch {
|
|
$script:Failures += "$Description`: $($_.Exception.Message)"
|
|
Write-Warning $script:Failures[-1]
|
|
}
|
|
}
|
|
|
|
$claude = Get-PortableMcps (Get-ClaudeMcps) "Claude"
|
|
$codex = Get-PortableMcps (Get-CodexMcps) "Codex"
|
|
$onlyClaude = @($claude.Keys | Where-Object { -not $codex.ContainsKey($_) } | Sort-Object)
|
|
$onlyCodex = @($codex.Keys | Where-Object { -not $claude.ContainsKey($_) } | Sort-Object)
|
|
$drift = @($claude.Keys | Where-Object {
|
|
$codex.ContainsKey($_) -and (Get-ComparableJson $claude[$_] ) -ne (Get-ComparableJson $codex[$_])
|
|
} | Sort-Object)
|
|
$matched = @($claude.Keys | Where-Object {
|
|
$codex.ContainsKey($_) -and (Get-ComparableJson $claude[$_] ) -eq (Get-ComparableJson $codex[$_])
|
|
} | Sort-Object)
|
|
|
|
foreach ($name in @($claude.Keys | Where-Object { $codex.ContainsKey($_) } | Sort-Object)) {
|
|
Write-Verbose "$name Claude canonical: $(Get-ComparableJson $claude[$name])"
|
|
Write-Verbose "$name Codex canonical: $(Get-ComparableJson $codex[$name])"
|
|
}
|
|
|
|
Write-Host "=== MCP bidirectional audit ==="
|
|
Write-Host "Claude only : $($onlyClaude -join ', ')"
|
|
Write-Host "Codex only : $($onlyCodex -join ', ')"
|
|
Write-Host "Drift : $($drift -join ', ')"
|
|
Write-Host "Matched : $($matched -join ', ')"
|
|
|
|
if ($Apply) {
|
|
foreach ($name in $onlyClaude) {
|
|
Try-Action "copied $name from Claude to Codex" { Add-ToCodex $claude[$name] }
|
|
}
|
|
foreach ($name in $onlyCodex) {
|
|
Try-Action "copied $name from Codex to Claude" { Add-ToClaude $codex[$name] }
|
|
}
|
|
foreach ($name in $drift) {
|
|
if ($ConflictAuthority -eq "Claude") {
|
|
Try-Action "replaced Codex $name from Claude authority" {
|
|
Remove-FromCodex $name
|
|
Add-ToCodex $claude[$name]
|
|
}
|
|
} elseif ($ConflictAuthority -eq "Codex") {
|
|
Try-Action "replaced Claude $name from Codex authority" {
|
|
Remove-FromClaude $name
|
|
Add-ToClaude $codex[$name]
|
|
}
|
|
} else {
|
|
Write-Warning "not overwriting drifted MCP '$name'; choose -ConflictAuthority Claude or Codex"
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($Apply) {
|
|
# Codex re-reads config.toml on every invocation, so this confirms what it will actually load.
|
|
$after = Get-PortableMcps (Get-CodexMcps) "Codex"
|
|
$stillMissing = @($claude.Keys | Where-Object { -not $after.ContainsKey($_) } | Sort-Object)
|
|
Write-Host "=== post-apply verification (codex mcp list) ==="
|
|
Write-Host "Codex now serves: $(@($after.Keys | Sort-Object) -join ', ')"
|
|
if ($stillMissing) { $script:Failures += "still absent from Codex after apply: $($stillMissing -join ', ')" }
|
|
}
|
|
|
|
if ($script:Unsupported.Count -gt 0) {
|
|
Write-Host "=== registered, but incomplete (codex-cli cannot express these keys) ==="
|
|
$script:Unsupported | ForEach-Object { Write-Host "- $_" }
|
|
}
|
|
|
|
if ($script:Failures.Count -gt 0) {
|
|
Write-Host "=== failures ==="
|
|
$script:Failures | ForEach-Object { Write-Host "- $_" }
|
|
exit 1
|
|
}
|
|
|
|
if (-not $Apply -and ($onlyClaude.Count + $onlyCodex.Count + $drift.Count -gt 0)) { exit 2 }
|
|
Write-Host "=== MCP audit complete ==="
|