From a1dcda6bc40ed3e5447991426dc7f90fcd114fdd Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Sat, 11 Apr 2026 00:04:15 -0500 Subject: [PATCH] feat(cli): support OpenClaw in Docker-native installs Add host-managed OpenClaw wrap and unwrap flows to the Docker-native wrappers so the installed headroom script can configure the OpenClaw plugin on the host while keeping Headroom itself in Docker. Reuse hidden prepare-only hooks for OpenClaw config payloads, preserve existing plugin metadata on unwrap, and update the Docker-native and integration docs to reflect the supported flow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- docs/cli.md | 12 +- docs/docker-install.md | 10 +- docs/integration-guide.md | 10 +- docs/typescript-sdk.md | 2 +- headroom/cli/wrap.py | 86 ++++- plugins/openclaw/README.md | 2 + scripts/install.ps1 | 515 ++++++++++++++++++++++++++- scripts/install.sh | 467 +++++++++++++++++++++++- tests/test_cli/test_wrap_bridge.py | 54 +++ tests/test_cli/test_wrap_openclaw.py | 18 + 11 files changed, 1137 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 0d47455b1..534cd6b74 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ headroom wrap codex --memory # Shares the same memory store Headroom starts a proxy, points your tool at it, and compresses everything automatically. Add `--memory` for persistent memory that's shared across agents. -In Docker-native mode, Headroom still runs in Docker while wrapped tools run on the host. `wrap claude`, `wrap codex`, `wrap aider`, and `wrap cursor` are supported; OpenClaw wrap/unwrap still require a native install. +In Docker-native mode, Headroom still runs in Docker while wrapped tools run on the host. `wrap claude`, `wrap codex`, `wrap aider`, `wrap cursor`, and OpenClaw plugin setup (`wrap openclaw` / `unwrap openclaw`) are host-managed through the installed wrapper. ### Multi-agent — SharedContext diff --git a/docs/cli.md b/docs/cli.md index 4a50d13b7..302823bb5 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -32,8 +32,8 @@ This page is the authoritative reference for the **Python Headroom CLI** exposed | `headroom wrap codex` | Start proxy and launch Codex CLI | **host-bridged** | | `headroom wrap aider` | Start proxy and launch Aider | **host-bridged** | | `headroom wrap cursor` | Start proxy and print Cursor config guidance | **host-bridged** | -| `headroom wrap openclaw` | Install and configure the OpenClaw plugin | **unsupported in Docker-native mode** | -| `headroom unwrap openclaw` | Disable the Headroom OpenClaw plugin | **unsupported in Docker-native mode** | +| `headroom wrap openclaw` | Install and configure the OpenClaw plugin | **host-bridged** | +| `headroom unwrap openclaw` | Disable the Headroom OpenClaw plugin | **host-bridged** | ## Captured `--help` output @@ -650,7 +650,7 @@ headroom wrap openclaw --plugin-path ./plugins/openclaw | `--no-restart` | off | Do not restart the OpenClaw gateway | | `--verbose`, `-v` | off | Verbose output | -Requires the `openclaw` binary on the host, and local-source mode may also require `npm`. +Requires the `openclaw` binary on the host, and local-source mode may also require `npm`. In Docker-native mode, the installed host wrapper drives the host `openclaw` CLI while the plugin auto-starts the host `headroom` wrapper from `PATH`. ## `headroom unwrap` @@ -678,8 +678,6 @@ Legend: - **native in container** — the command runs entirely inside the Headroom container - **host-bridged** — Headroom runs in Docker, but the wrapped external tool still runs on the host -- **unsupported** — use a native Python install of Headroom - | Command path | Python CLI | Docker-native wrapper | Parity | |---|---|---|---| | `headroom proxy` | native | native in container | full | @@ -696,8 +694,8 @@ Legend: | `headroom wrap codex` | native | host-bridged | partial | | `headroom wrap aider` | native | host-bridged | partial | | `headroom wrap cursor` | native | host-bridged | partial | -| `headroom wrap openclaw` | native | unsupported | none | -| `headroom unwrap openclaw` | native | unsupported | none | +| `headroom wrap openclaw` | native | host-bridged | partial | +| `headroom unwrap openclaw` | native | host-bridged | partial | For the Docker-native execution model itself, see [Docker-Native Install](docker-install.md). diff --git a/docs/docker-install.md b/docs/docker-install.md index 77af01bcd..98bee18bf 100644 --- a/docs/docker-install.md +++ b/docs/docker-install.md @@ -71,13 +71,15 @@ Supported host wrap flows: - `headroom wrap codex` - `headroom wrap aider` - `headroom wrap cursor` - -Not yet supported in Docker-native mode: - - `headroom wrap openclaw` - `headroom unwrap openclaw` -Those still require a native Headroom install because OpenClaw plugin install and gateway lifecycle management are host-native operations. +OpenClaw remains host-native in Docker-native mode: + +- the host must already have the `openclaw` CLI installed +- `headroom wrap openclaw` installs/configures the Headroom plugin through the host `openclaw` CLI +- plugin auto-start still launches the installed host `headroom` wrapper from `PATH`, which then runs Headroom in Docker +- local plugin source mode (`--plugin-path`) is also supported, but it may require host `npm` when build steps are needed ## Docker Compose support diff --git a/docs/integration-guide.md b/docs/integration-guide.md index ab85df0aa..a100846c4 100644 --- a/docs/integration-guide.md +++ b/docs/integration-guide.md @@ -270,8 +270,7 @@ See the [TypeScript SDK Guide](typescript-sdk.md) for full documentation includi Context compression plugin for [OpenClaw](https://github.com/openclaw/openclaw) agents. ```bash -pip install "headroom-ai[proxy]" -openclaw plugins install headroom-openclaw +headroom wrap openclaw ``` Configure as context engine: @@ -279,6 +278,13 @@ Configure as context engine: { "plugins": { "slots": { "contextEngine": "headroom" } } } ``` +Manual install remains available when you are not using the CLI wrapper: + +```bash +pip install "headroom-ai[proxy]" +openclaw plugins install --dangerously-force-unsafe-install headroom-ai/openclaw +``` + The plugin auto-detects a running Headroom proxy or starts one. Compression happens in `assemble()` — zero changes to the agent's behavior. See the [OpenClaw plugin documentation](https://github.com/chopratejas/headroom/tree/main/plugins/openclaw) for full setup. diff --git a/docs/typescript-sdk.md b/docs/typescript-sdk.md index ebd3ed180..b7df0d4cb 100644 --- a/docs/typescript-sdk.md +++ b/docs/typescript-sdk.md @@ -200,7 +200,7 @@ The `headroom-ai` package has no runtime dependencies. Framework SDKs (Vercel AI ## OpenClaw Plugin -The TypeScript SDK powers the [`headroom-openclaw`](https://www.npmjs.com/package/headroom-openclaw) plugin for [OpenClaw](https://github.com/openclaw/openclaw) agents. The plugin uses `HeadroomClient` internally to compress context during the `assemble()` lifecycle hook. Install it with `openclaw plugins install headroom-openclaw`. See the [plugin source](https://github.com/chopratejas/headroom/tree/main/plugins/openclaw) for details. +The TypeScript SDK powers the [`headroom-openclaw`](https://www.npmjs.com/package/headroom-openclaw) plugin for [OpenClaw](https://github.com/openclaw/openclaw) agents. The plugin uses `HeadroomClient` internally to compress context during the `assemble()` lifecycle hook. The preferred install flow is `headroom wrap openclaw`; the direct plugin command is `openclaw plugins install --dangerously-force-unsafe-install headroom-ai/openclaw`. See the [plugin source](https://github.com/chopratejas/headroom/tree/main/plugins/openclaw) for details. ## Comparison with Python SDK diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index 52461d8d6..645b54dcc 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -549,6 +549,17 @@ def _read_openclaw_config_value(openclaw_bin: str, path: str) -> Any | None: return output +def _decode_openclaw_entry_json(raw_value: str | None) -> Any | None: + """Decode a JSON payload captured from `openclaw config get` when available.""" + if not raw_value: + return None + + try: + return json.loads(raw_value) + except json.JSONDecodeError: + return raw_value + + def _build_openclaw_plugin_entry( *, existing_entry: Any, @@ -583,6 +594,28 @@ def _build_openclaw_plugin_entry( } +def _build_openclaw_unwrap_entry(existing_entry: Any) -> dict[str, object]: + """Disable the managed plugin while preserving unrelated user config.""" + base_entry = existing_entry if isinstance(existing_entry, dict) else {} + existing_config = {} + if isinstance(existing_entry, dict) and isinstance(existing_entry.get("config"), dict): + existing_config = { + key: value + for key, value in existing_entry["config"].items() + if key + not in { + "gatewayProviderIds", + "proxyUrl", + "proxyPort", + "autoStart", + "startupTimeoutMs", + "pythonPath", + } + } + + return {**base_entry, "enabled": False, "config": existing_config} + + def _write_openclaw_plugin_entry(openclaw_bin: str, entry: dict[str, object]) -> None: """Persist the Headroom plugin config entry.""" _run_checked( @@ -1132,6 +1165,8 @@ def cursor( help="Do not restart OpenClaw gateway at the end", ) @click.option("--verbose", "-v", is_flag=True, help="Verbose output") +@click.option("--prepare-only", is_flag=True, hidden=True) +@click.option("--existing-entry-json", default=None, hidden=True) def openclaw( plugin_path: Path | None, plugin_spec: str, @@ -1144,6 +1179,8 @@ def openclaw( no_auto_start: bool, no_restart: bool, verbose: bool, + prepare_only: bool, + existing_entry_json: str | None, ) -> None: """Install and configure Headroom OpenClaw plugin in one command. @@ -1160,6 +1197,19 @@ def openclaw( headroom wrap openclaw headroom wrap openclaw --plugin-path C:\\git\\headroom\\plugins\\openclaw """ + if prepare_only: + entry = _build_openclaw_plugin_entry( + existing_entry=_decode_openclaw_entry_json(existing_entry_json), + proxy_port=proxy_port, + startup_timeout_ms=startup_timeout_ms, + python_path=python_path, + no_auto_start=no_auto_start, + gateway_provider_ids=gateway_provider_ids, + enabled=True, + ) + click.echo(json.dumps(entry, separators=(",", ":"))) + return + openclaw_bin = shutil.which("openclaw") if not openclaw_bin: raise click.ClickException("'openclaw' not found in PATH. Install OpenClaw CLI first.") @@ -1304,8 +1354,24 @@ def openclaw( @unwrap.command("openclaw") @click.option("--no-restart", is_flag=True, help="Do not restart OpenClaw gateway at the end") @click.option("--verbose", "-v", is_flag=True, help="Verbose output") -def unwrap_openclaw(no_restart: bool, verbose: bool) -> None: +@click.option("--prepare-only", is_flag=True, hidden=True) +@click.option("--existing-entry-json", default=None, hidden=True) +def unwrap_openclaw( + no_restart: bool, + verbose: bool, + prepare_only: bool, + existing_entry_json: str | None, +) -> None: """Disable the Headroom OpenClaw plugin and restore the legacy engine slot.""" + if prepare_only: + click.echo( + json.dumps( + _build_openclaw_unwrap_entry(_decode_openclaw_entry_json(existing_entry_json)), + separators=(",", ":"), + ) + ) + return + openclaw_bin = shutil.which("openclaw") if not openclaw_bin: raise click.ClickException("'openclaw' not found in PATH. Install OpenClaw CLI first.") @@ -1318,23 +1384,7 @@ def unwrap_openclaw(no_restart: bool, verbose: bool) -> None: click.echo(" Disabling Headroom plugin and removing engine mapping...") existing_entry = _read_openclaw_config_value(openclaw_bin, "plugins.entries.headroom") - existing_config = {} - if isinstance(existing_entry, dict) and isinstance(existing_entry.get("config"), dict): - existing_config = { - key: value - for key, value in existing_entry["config"].items() - if key - not in { - "gatewayProviderIds", - "proxyUrl", - "proxyPort", - "autoStart", - "startupTimeoutMs", - "pythonPath", - } - } - - entry = {"enabled": False, "config": existing_config} + entry = _build_openclaw_unwrap_entry(existing_entry) _write_openclaw_plugin_entry(openclaw_bin, entry) _set_openclaw_context_engine_slot(openclaw_bin, "legacy") _run_checked( diff --git a/plugins/openclaw/README.md b/plugins/openclaw/README.md index 973bc1033..b8093547c 100644 --- a/plugins/openclaw/README.md +++ b/plugins/openclaw/README.md @@ -150,6 +150,8 @@ When `proxyUrl` points to localhost (or is omitted), the plugin will auto-start If `pythonPath` is set, it is tried first in the Python fallback step. +Docker-native Headroom installs intentionally leave `pythonPath` unset so this launcher order prefers the installed host `headroom` wrapper on `PATH`, which then runs Headroom in Docker. + ### Remote proxy (connect-only) Point `proxyUrl` to any reachable Headroom instance: diff --git a/scripts/install.ps1 b/scripts/install.ps1 index b667da331..ca9c1dc37 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -265,6 +265,496 @@ function Invoke-WithTemporaryEnv { } } +function Test-HelpFlag { + param([string[]]$Arguments) + + foreach ($arg in $Arguments) { + if ($arg -eq '--help' -or $arg -eq '-?') { + return $true + } + } + + return $false +} + +function Parse-OpenClawWrapArgs { + param([string[]]$Arguments) + + $gatewayProviderIds = New-Object System.Collections.Generic.List[string] + $pluginPath = $null + $pluginSpec = 'headroom-ai/openclaw' + $skipBuild = $false + $copy = $false + $proxyPort = 8787 + $startupTimeoutMs = 20000 + $pythonPath = $null + $noAutoStart = $false + $noRestart = $false + $verbose = $false + + $i = 0 + while ($i -lt $Arguments.Count) { + $arg = $Arguments[$i] + switch -Regex ($arg) { + '^--plugin-path$' { + $pluginPath = $Arguments[$i + 1] + $i += 2 + continue + } + '^--plugin-path=' { + $pluginPath = $arg -replace '^--plugin-path=', '' + $i += 1 + continue + } + '^--plugin-spec$' { + $pluginSpec = $Arguments[$i + 1] + $i += 2 + continue + } + '^--plugin-spec=' { + $pluginSpec = $arg -replace '^--plugin-spec=', '' + $i += 1 + continue + } + '^--skip-build$' { + $skipBuild = $true + $i += 1 + continue + } + '^--copy$' { + $copy = $true + $i += 1 + continue + } + '^--proxy-port$' { + $proxyPort = [int]$Arguments[$i + 1] + $i += 2 + continue + } + '^--proxy-port=' { + $proxyPort = [int]($arg -replace '^--proxy-port=', '') + $i += 1 + continue + } + '^--startup-timeout-ms$' { + $startupTimeoutMs = [int]$Arguments[$i + 1] + $i += 2 + continue + } + '^--startup-timeout-ms=' { + $startupTimeoutMs = [int]($arg -replace '^--startup-timeout-ms=', '') + $i += 1 + continue + } + '^--gateway-provider-id$' { + $gatewayProviderIds.Add($Arguments[$i + 1]) + $i += 2 + continue + } + '^--gateway-provider-id=' { + $gatewayProviderIds.Add($arg -replace '^--gateway-provider-id=', '') + $i += 1 + continue + } + '^--python-path$' { + $pythonPath = $Arguments[$i + 1] + $i += 2 + continue + } + '^--python-path=' { + $pythonPath = $arg -replace '^--python-path=', '' + $i += 1 + continue + } + '^--no-auto-start$' { + $noAutoStart = $true + $i += 1 + continue + } + '^--no-restart$' { + $noRestart = $true + $i += 1 + continue + } + '^--verbose$|^-v$' { + $verbose = $true + $i += 1 + continue + } + default { + Fail "Unsupported option for 'headroom wrap openclaw': $arg" + } + } + } + + [pscustomobject]@{ + PluginPath = $pluginPath + PluginSpec = $pluginSpec + SkipBuild = $skipBuild + Copy = $copy + ProxyPort = $proxyPort + StartupTimeoutMs = $startupTimeoutMs + GatewayProviderIds = $gatewayProviderIds.ToArray() + PythonPath = $pythonPath + NoAutoStart = $noAutoStart + NoRestart = $noRestart + Verbose = $verbose + } +} + +function Parse-OpenClawUnwrapArgs { + param([string[]]$Arguments) + + $noRestart = $false + $verbose = $false + $i = 0 + while ($i -lt $Arguments.Count) { + $arg = $Arguments[$i] + switch -Regex ($arg) { + '^--no-restart$' { + $noRestart = $true + $i += 1 + continue + } + '^--verbose$|^-v$' { + $verbose = $true + $i += 1 + continue + } + default { + Fail "Unsupported option for 'headroom unwrap openclaw': $arg" + } + } + } + + [pscustomobject]@{ + NoRestart = $noRestart + Verbose = $verbose + } +} + +function Invoke-CapturedCommand { + param( + [string]$Action, + [string]$Command, + [string[]]$Arguments, + [string]$WorkingDirectory + ) + + $previous = $null + try { + if ($WorkingDirectory) { + $previous = Get-Location + Set-Location $WorkingDirectory + } + + $output = (& $Command @Arguments 2>&1 | Out-String).Trim() + $exitCode = $LASTEXITCODE + } finally { + if ($previous) { + Set-Location $previous + } + } + + if ($exitCode -ne 0) { + if (-not $output) { + $output = "exit code $exitCode" + } + Fail "$Action failed: $output" + } + + return $output +} + +function Get-OpenClawExistingEntryJson { + $output = (& openclaw config get plugins.entries.headroom 2>$null | Out-String).Trim() + if ($LASTEXITCODE -ne 0) { + return $null + } + return $output +} + +function Invoke-OpenClawPrepareEntryJson { + param( + [string]$ExistingEntryJson, + [pscustomobject]$Parsed + ) + + $dockerArgs = New-Object System.Collections.Generic.List[string] + $dockerArgs.AddRange(@('run','--rm')) + $dockerArgs.AddRange((Get-SharedDockerArgs)) + $dockerArgs.Add('--entrypoint') + $dockerArgs.Add('headroom') + $dockerArgs.Add($HeadroomImage) + $dockerArgs.AddRange(@('wrap','openclaw','--prepare-only','--proxy-port',"$($Parsed.ProxyPort)",'--startup-timeout-ms',"$($Parsed.StartupTimeoutMs)")) + if ($ExistingEntryJson) { + $dockerArgs.Add('--existing-entry-json') + $dockerArgs.Add($ExistingEntryJson) + } + if ($Parsed.PythonPath) { + $dockerArgs.Add('--python-path') + $dockerArgs.Add($Parsed.PythonPath) + } + if ($Parsed.NoAutoStart) { + $dockerArgs.Add('--no-auto-start') + } + foreach ($providerId in $Parsed.GatewayProviderIds) { + $dockerArgs.Add('--gateway-provider-id') + $dockerArgs.Add($providerId) + } + + $output = (& docker @dockerArgs 2>&1 | Out-String).Trim() + if ($LASTEXITCODE -ne 0) { + Fail "Failed to prepare docker-native OpenClaw config: $output" + } + + return $output +} + +function Invoke-OpenClawPrepareUnwrapEntryJson { + param([string]$ExistingEntryJson) + + $dockerArgs = New-Object System.Collections.Generic.List[string] + $dockerArgs.AddRange(@('run','--rm')) + $dockerArgs.AddRange((Get-SharedDockerArgs)) + $dockerArgs.Add('--entrypoint') + $dockerArgs.Add('headroom') + $dockerArgs.Add($HeadroomImage) + $dockerArgs.AddRange(@('unwrap','openclaw','--prepare-only')) + if ($ExistingEntryJson) { + $dockerArgs.Add('--existing-entry-json') + $dockerArgs.Add($ExistingEntryJson) + } + + $output = (& docker @dockerArgs 2>&1 | Out-String).Trim() + if ($LASTEXITCODE -ne 0) { + Fail "Failed to prepare docker-native OpenClaw unwrap config: $output" + } + + return $output +} + +function Resolve-OpenClawExtensionsDir { + $configOutput = Invoke-CapturedCommand -Action 'openclaw config file' -Command 'openclaw' -Arguments @('config','file') + $configPath = ($configOutput -split "`r?`n")[-1].Trim() + if (-not $configPath) { + Fail 'Unable to resolve OpenClaw config path.' + } + return (Join-Path (Split-Path -Parent $configPath) 'extensions') +} + +function Copy-OpenClawPluginIntoExtensions { + param([string]$PluginPath) + + $distDir = Join-Path $PluginPath 'dist' + $hookShimDir = Join-Path $PluginPath 'hook-shim' + if (-not (Test-Path $distDir)) { + Fail "Plugin dist folder missing at $distDir. Build the plugin first." + } + if (-not (Test-Path $hookShimDir)) { + Fail "Plugin hook-shim folder missing at $hookShimDir. Build the plugin first." + } + + $extensionsDir = Resolve-OpenClawExtensionsDir + $targetDir = Join-Path $extensionsDir 'headroom' + $targetDist = Join-Path $targetDir 'dist' + $targetHookShim = Join-Path $targetDir 'hook-shim' + New-Item -ItemType Directory -Force -Path $targetDir | Out-Null + if (Test-Path $targetDist) { Remove-Item -Recurse -Force $targetDist } + if (Test-Path $targetHookShim) { Remove-Item -Recurse -Force $targetHookShim } + Copy-Item -Recurse -Force $distDir $targetDist + Copy-Item -Recurse -Force $hookShimDir $targetHookShim + + foreach ($fileName in @('openclaw.plugin.json','package.json','README.md')) { + $source = Join-Path $PluginPath $fileName + if (Test-Path $source) { + Copy-Item -Force $source (Join-Path $targetDir $fileName) + } + } + + return $targetDir +} + +function Install-OpenClawPlugin { + param([pscustomobject]$Parsed) + + if ($Parsed.PluginPath) { + if (-not (Test-Path $Parsed.PluginPath)) { + Fail "Plugin path not found: $($Parsed.PluginPath)." + } + if (-not (Test-Path (Join-Path $Parsed.PluginPath 'package.json'))) { + Fail "Invalid plugin path (missing package.json): $($Parsed.PluginPath)" + } + if (-not (Test-Path (Join-Path $Parsed.PluginPath 'openclaw.plugin.json'))) { + Fail "Invalid plugin path (missing openclaw.plugin.json): $($Parsed.PluginPath)" + } + } + + if ($Parsed.PluginPath -and -not $Parsed.SkipBuild) { + Require-Command npm + Write-Host ' Building OpenClaw plugin (npm install + npm run build)...' + [void](Invoke-CapturedCommand -Action 'npm install' -Command 'npm' -Arguments @('install') -WorkingDirectory $Parsed.PluginPath) + [void](Invoke-CapturedCommand -Action 'npm run build' -Command 'npm' -Arguments @('run','build') -WorkingDirectory $Parsed.PluginPath) + } + + if ($Parsed.PluginPath) { + if ($Parsed.Copy) { + $arguments = @('plugins','install','--dangerously-force-unsafe-install',$Parsed.PluginPath) + $workingDirectory = $null + } else { + $arguments = @('plugins','install','--dangerously-force-unsafe-install','--link','.') + $workingDirectory = $Parsed.PluginPath + } + } else { + $arguments = @('plugins','install','--dangerously-force-unsafe-install',$Parsed.PluginSpec) + $workingDirectory = $null + } + + $previous = $null + try { + if ($workingDirectory) { + $previous = Get-Location + Set-Location $workingDirectory + } + $installOutput = (& openclaw @arguments 2>&1 | Out-String).Trim() + $installExitCode = $LASTEXITCODE + } finally { + if ($previous) { + Set-Location $previous + } + } + + if ($installExitCode -eq 0) { + if ($Parsed.Verbose -and $installOutput) { + Write-Host $installOutput + } + return + } + + $lowerOutput = $installOutput.ToLowerInvariant() + if ($lowerOutput.Contains('plugin already exists')) { + Write-Host ' Plugin already installed; continuing with configuration/update steps.' + return + } + + if ($Parsed.PluginPath -and -not $Parsed.Copy -and $lowerOutput.Contains('also not a valid hook pack')) { + Write-Host ' OpenClaw linked-path install bug detected; applying extension-path fallback...' + $targetDir = Copy-OpenClawPluginIntoExtensions -PluginPath $Parsed.PluginPath + Write-Host " Fallback plugin copy completed: $targetDir" + return + } + + if (-not $installOutput) { + $installOutput = "exit code $installExitCode" + } + Fail "openclaw plugins install failed: $installOutput" +} + +function Restart-OrStartOpenClawGateway { + $restartOutput = (& openclaw gateway restart 2>&1 | Out-String).Trim() + if ($LASTEXITCODE -eq 0) { + return [pscustomobject]@{ Action = 'restarted'; Output = $restartOutput } + } + + $startOutput = Invoke-CapturedCommand -Action 'openclaw gateway start' -Command 'openclaw' -Arguments @('gateway','start') + return [pscustomobject]@{ Action = 'started'; Output = $startOutput } +} + +function Invoke-OpenClawWrap { + param([string[]]$Arguments) + + Require-Command openclaw + $parsed = Parse-OpenClawWrapArgs -Arguments $Arguments + $existingEntryJson = Get-OpenClawExistingEntryJson + $entryJson = Invoke-OpenClawPrepareEntryJson -ExistingEntryJson $existingEntryJson -Parsed $parsed + + Write-Host "" + Write-Host " ╔═══════════════════════════════════════════════╗" + Write-Host " ║ HEADROOM WRAP: OPENCLAW ║" + Write-Host " ╚═══════════════════════════════════════════════╝" + Write-Host "" + if ($parsed.PluginPath) { + Write-Host " Plugin source: local ($($parsed.PluginPath))" + } else { + Write-Host " Plugin source: npm ($($parsed.PluginSpec))" + } + + Write-Host ' Writing plugin configuration...' + [void](Invoke-CapturedCommand -Action 'openclaw config set plugins.entries.headroom' -Command 'openclaw' -Arguments @('config','set','plugins.entries.headroom',$entryJson,'--strict-json')) + Write-Host ' Installing OpenClaw plugin with required unsafe-install flag...' + Install-OpenClawPlugin -Parsed $parsed + [void](Invoke-CapturedCommand -Action 'openclaw config set plugins.slots.contextEngine' -Command 'openclaw' -Arguments @('config','set','plugins.slots.contextEngine','"headroom"','--strict-json')) + [void](Invoke-CapturedCommand -Action 'openclaw config validate' -Command 'openclaw' -Arguments @('config','validate')) + + if ($parsed.NoRestart) { + Write-Host ' Skipping gateway restart (--no-restart).' + Write-Host ' Run `openclaw gateway restart` (or `openclaw gateway start`) to apply plugin changes.' + } else { + Write-Host ' Applying plugin changes to OpenClaw gateway...' + $gatewayResult = Restart-OrStartOpenClawGateway + Write-Host " Gateway $($gatewayResult.Action)." + if ($parsed.Verbose -and $gatewayResult.Output) { + Write-Host $gatewayResult.Output + } + } + + $inspectOutput = Invoke-CapturedCommand -Action 'openclaw plugins inspect headroom' -Command 'openclaw' -Arguments @('plugins','inspect','headroom') + if ($parsed.Verbose -and $inspectOutput) { + Write-Host $inspectOutput + } + + Write-Host "" + Write-Host "✓ OpenClaw is configured to use Headroom context compression." + Write-Host " Plugin: headroom" + Write-Host " Slot: plugins.slots.contextEngine = headroom" + Write-Host "" +} + +function Invoke-OpenClawUnwrap { + param([string[]]$Arguments) + + Require-Command openclaw + $parsed = Parse-OpenClawUnwrapArgs -Arguments $Arguments + $existingEntryJson = Get-OpenClawExistingEntryJson + $entryJson = Invoke-OpenClawPrepareUnwrapEntryJson -ExistingEntryJson $existingEntryJson + + Write-Host "" + Write-Host " ╔═══════════════════════════════════════════════╗" + Write-Host " ║ HEADROOM UNWRAP: OPENCLAW ║" + Write-Host " ╚═══════════════════════════════════════════════╝" + Write-Host "" + Write-Host ' Disabling Headroom plugin and removing engine mapping...' + + [void](Invoke-CapturedCommand -Action 'openclaw config set plugins.entries.headroom' -Command 'openclaw' -Arguments @('config','set','plugins.entries.headroom',$entryJson,'--strict-json')) + [void](Invoke-CapturedCommand -Action 'openclaw config set plugins.slots.contextEngine' -Command 'openclaw' -Arguments @('config','set','plugins.slots.contextEngine','"legacy"','--strict-json')) + [void](Invoke-CapturedCommand -Action 'openclaw config validate' -Command 'openclaw' -Arguments @('config','validate')) + + if ($parsed.NoRestart) { + Write-Host ' Skipping gateway restart (--no-restart).' + Write-Host ' Run `openclaw gateway restart` (or `openclaw gateway start`) to apply unwrap changes.' + } else { + Write-Host ' Applying unwrap changes to OpenClaw gateway...' + $gatewayResult = Restart-OrStartOpenClawGateway + Write-Host " Gateway $($gatewayResult.Action)." + if ($parsed.Verbose -and $gatewayResult.Output) { + Write-Host $gatewayResult.Output + } + } + + if ($parsed.Verbose) { + $inspectOutput = Invoke-CapturedCommand -Action 'openclaw plugins inspect headroom' -Command 'openclaw' -Arguments @('plugins','inspect','headroom') + if ($inspectOutput) { + Write-Host $inspectOutput + } + } + + Write-Host "" + Write-Host "✓ OpenClaw Headroom wrap removed." + Write-Host " Plugin: headroom (installed, disabled)" + Write-Host " Slot: plugins.slots.contextEngine = legacy" + Write-Host "" +} + function Parse-WrapArgs { param([string[]]$Arguments) @@ -421,11 +911,23 @@ if ($args.Count -eq 0) { switch ($args[0]) { 'wrap' { if ($args.Count -lt 2) { - Fail 'Usage: headroom wrap [...]' + Fail 'Usage: headroom wrap [...]' } $tool = $args[1] $wrapArgs = if ($args.Count -gt 2) { $args[2..($args.Count - 1)] } else { @() } + + if ($tool -eq 'openclaw') { + if (Test-HelpFlag -Arguments $wrapArgs) { + $helpArgs = @('wrap','openclaw') + $wrapArgs + Invoke-HeadroomDocker -Arguments $helpArgs + exit 0 + } + + Invoke-OpenClawWrap -Arguments $wrapArgs + exit 0 + } + $parsed = Parse-WrapArgs -Arguments $wrapArgs $proxyArgs = New-Object System.Collections.Generic.List[string] if ($parsed.Learn) { $proxyArgs.Add('--learn') } @@ -438,7 +940,6 @@ switch ($args[0]) { 'codex' { } 'aider' { } 'cursor' { } - 'openclaw' { Fail "Docker-native install does not support 'headroom wrap openclaw' yet. Use a native Headroom install for OpenClaw plugin management." } default { Fail "Unsupported wrap target: $tool" } } @@ -490,7 +991,15 @@ switch ($args[0]) { } 'unwrap' { if ($args.Count -ge 2 -and $args[1] -eq 'openclaw') { - Fail "Docker-native install does not support 'headroom unwrap openclaw' yet. Use a native Headroom install for OpenClaw plugin management." + $unwrapArgs = if ($args.Count -gt 2) { $args[2..($args.Count - 1)] } else { @() } + if (Test-HelpFlag -Arguments $unwrapArgs) { + $helpArgs = @('unwrap','openclaw') + $unwrapArgs + Invoke-HeadroomDocker -Arguments $helpArgs + exit 0 + } + + Invoke-OpenClawUnwrap -Arguments $unwrapArgs + exit 0 } Invoke-HeadroomDocker -Arguments $args } diff --git a/scripts/install.sh b/scripts/install.sh index 4314dec64..d9845cf6f 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -338,6 +338,451 @@ run_host_tool() { "${binary}" "$@" } +contains_help_flag() { + local arg + for arg in "$@"; do + if [[ "${arg}" == "--help" || "${arg}" == "-?" ]]; then + return 0 + fi + done + + return 1 +} + +parse_openclaw_wrap_args() { + local -n out_plugin_path=$1 + local -n out_plugin_spec=$2 + local -n out_skip_build=$3 + local -n out_copy=$4 + local -n out_proxy_port=$5 + local -n out_startup_timeout_ms=$6 + local -n out_gateway_provider_ids=$7 + local -n out_python_path=$8 + local -n out_no_auto_start=$9 + local -n out_no_restart=${10} + local -n out_verbose=${11} + shift 11 + + out_plugin_path="" + out_plugin_spec="headroom-ai/openclaw" + out_skip_build=0 + out_copy=0 + out_proxy_port=8787 + out_startup_timeout_ms=20000 + out_gateway_provider_ids=() + out_python_path="" + out_no_auto_start=0 + out_no_restart=0 + out_verbose=0 + + while (($#)); do + case "$1" in + --plugin-path) + out_plugin_path="$2" + shift 2 + ;; + --plugin-path=*) + out_plugin_path="${1#*=}" + shift + ;; + --plugin-spec) + out_plugin_spec="$2" + shift 2 + ;; + --plugin-spec=*) + out_plugin_spec="${1#*=}" + shift + ;; + --skip-build) + out_skip_build=1 + shift + ;; + --copy) + out_copy=1 + shift + ;; + --proxy-port) + out_proxy_port="$2" + shift 2 + ;; + --proxy-port=*) + out_proxy_port="${1#*=}" + shift + ;; + --startup-timeout-ms) + out_startup_timeout_ms="$2" + shift 2 + ;; + --startup-timeout-ms=*) + out_startup_timeout_ms="${1#*=}" + shift + ;; + --gateway-provider-id) + out_gateway_provider_ids+=("$2") + shift 2 + ;; + --gateway-provider-id=*) + out_gateway_provider_ids+=("${1#*=}") + shift + ;; + --python-path) + out_python_path="$2" + shift 2 + ;; + --python-path=*) + out_python_path="${1#*=}" + shift + ;; + --no-auto-start) + out_no_auto_start=1 + shift + ;; + --no-restart) + out_no_restart=1 + shift + ;; + --verbose|-v) + out_verbose=1 + shift + ;; + *) + die "Unsupported option for 'headroom wrap openclaw': $1" + ;; + esac + done +} + +parse_openclaw_unwrap_args() { + local -n out_no_restart=$1 + local -n out_verbose=$2 + shift 2 + + out_no_restart=0 + out_verbose=0 + + while (($#)); do + case "$1" in + --no-restart) + out_no_restart=1 + shift + ;; + --verbose|-v) + out_verbose=1 + shift + ;; + *) + die "Unsupported option for 'headroom unwrap openclaw': $1" + ;; + esac + done +} + +get_openclaw_existing_entry_json() { + local output="" + if output="$(openclaw config get plugins.entries.headroom 2>/dev/null)"; then + printf '%s' "${output}" + fi +} + +prepare_openclaw_entry_json() { + local existing_entry_json="$1" + local proxy_port="$2" + local startup_timeout_ms="$3" + local python_path="$4" + local no_auto_start="$5" + shift 5 + local gateway_provider_ids=("$@") + local args=() + args=(docker run --rm) + append_common_container_args args + args+=(--entrypoint headroom "${HEADROOM_IMAGE}" wrap openclaw --prepare-only) + args+=(--proxy-port "${proxy_port}" --startup-timeout-ms "${startup_timeout_ms}") + + if [[ -n "${existing_entry_json}" ]]; then + args+=(--existing-entry-json "${existing_entry_json}") + fi + if [[ -n "${python_path}" ]]; then + args+=(--python-path "${python_path}") + fi + if [[ "${no_auto_start}" -eq 1 ]]; then + args+=(--no-auto-start) + fi + + local provider_id + for provider_id in "${gateway_provider_ids[@]}"; do + args+=(--gateway-provider-id "${provider_id}") + done + + "${args[@]}" +} + +prepare_openclaw_unwrap_entry_json() { + local existing_entry_json="$1" + local args=() + args=(docker run --rm) + append_common_container_args args + args+=(--entrypoint headroom "${HEADROOM_IMAGE}" unwrap openclaw --prepare-only) + if [[ -n "${existing_entry_json}" ]]; then + args+=(--existing-entry-json "${existing_entry_json}") + fi + "${args[@]}" +} + +run_openclaw_checked() { + local action="$1" + shift + local output="" + + if ! output="$("$@" 2>&1)"; then + output="${output//$'\r'/}" + die "${action} failed: ${output:-unknown error}" + fi + + printf '%s' "${output//$'\r'/}" +} + +run_openclaw_checked_in_dir() { + local action="$1" + local cwd="$2" + shift 2 + local output="" + + if ! output="$(cd "${cwd}" && "$@" 2>&1)"; then + output="${output//$'\r'/}" + die "${action} failed: ${output:-unknown error}" + fi + + printf '%s' "${output//$'\r'/}" +} + +resolve_openclaw_extensions_dir() { + local config_output + config_output="$(run_openclaw_checked "openclaw config file" openclaw config file)" + local config_path + config_path="$(printf '%s\n' "${config_output}" | tail -n 1)" + [[ -n "${config_path}" ]] || die "Unable to resolve OpenClaw config path." + printf '%s\n' "$(dirname "${config_path}")/extensions" +} + +copy_openclaw_plugin_into_extensions() { + local plugin_dir="$1" + local dist_dir="${plugin_dir}/dist" + local hook_shim_dir="${plugin_dir}/hook-shim" + [[ -d "${dist_dir}" ]] || die "Plugin dist folder missing at ${dist_dir}. Build the plugin first." + [[ -d "${hook_shim_dir}" ]] || die "Plugin hook-shim folder missing at ${hook_shim_dir}. Build the plugin first." + + local extensions_dir + extensions_dir="$(resolve_openclaw_extensions_dir)" + local target_dir="${extensions_dir}/headroom" + mkdir -p "${target_dir}" + rm -rf "${target_dir}/dist" "${target_dir}/hook-shim" + cp -R "${dist_dir}" "${target_dir}/dist" + cp -R "${hook_shim_dir}" "${target_dir}/hook-shim" + + local filename + for filename in openclaw.plugin.json package.json README.md; do + if [[ -f "${plugin_dir}/${filename}" ]]; then + cp "${plugin_dir}/${filename}" "${target_dir}/${filename}" + fi + done + + printf '%s\n' "${target_dir}" +} + +install_openclaw_plugin() { + local plugin_path="$1" + local plugin_spec="$2" + local skip_build="$3" + local copy_mode="$4" + local verbose="$5" + + local local_source_mode=0 + if [[ -n "${plugin_path}" ]]; then + local_source_mode=1 + [[ -d "${plugin_path}" ]] || die "Plugin path not found: ${plugin_path}." + [[ -f "${plugin_path}/package.json" ]] || die "Invalid plugin path (missing package.json): ${plugin_path}" + [[ -f "${plugin_path}/openclaw.plugin.json" ]] || die "Invalid plugin path (missing openclaw.plugin.json): ${plugin_path}" + fi + + if [[ "${local_source_mode}" -eq 1 && "${skip_build}" -eq 0 ]]; then + require_cmd npm + info "Building OpenClaw plugin (npm install + npm run build)..." + run_openclaw_checked_in_dir "npm install" "${plugin_path}" npm install >/dev/null + run_openclaw_checked_in_dir "npm run build" "${plugin_path}" npm run build >/dev/null + fi + + local install_output="" + local install_status=0 + set +e + if [[ "${local_source_mode}" -eq 1 ]]; then + if [[ "${copy_mode}" -eq 1 ]]; then + install_output="$(openclaw plugins install --dangerously-force-unsafe-install "${plugin_path}" 2>&1)" + install_status=$? + else + install_output="$(cd "${plugin_path}" && openclaw plugins install --dangerously-force-unsafe-install --link . 2>&1)" + install_status=$? + fi + else + install_output="$(openclaw plugins install --dangerously-force-unsafe-install "${plugin_spec}" 2>&1)" + install_status=$? + fi + set -e + install_output="${install_output//$'\r'/}" + + if [[ "${install_status}" -eq 0 ]]; then + if [[ "${verbose}" -eq 1 && -n "${install_output}" ]]; then + printf '%s\n' "${install_output}" + fi + return + fi + + local lower_output="${install_output,,}" + if [[ "${lower_output}" == *"plugin already exists"* ]]; then + info "Plugin already installed; continuing with configuration/update steps." + return + fi + + if [[ "${lower_output}" == *"also not a valid hook pack"* && "${local_source_mode}" -eq 1 && "${copy_mode}" -eq 0 ]]; then + info "OpenClaw linked-path install bug detected; applying extension-path fallback..." + local target_dir + target_dir="$(copy_openclaw_plugin_into_extensions "${plugin_path}")" + info "Fallback plugin copy completed: ${target_dir}" + return + fi + + die "openclaw plugins install failed: ${install_output:-exit code ${install_status}}" +} + +restart_or_start_openclaw_gateway() { + local output="" + if output="$(openclaw gateway restart 2>&1)"; then + OPENCLAW_GATEWAY_ACTION="restarted" + OPENCLAW_GATEWAY_OUTPUT="${output//$'\r'/}" + return + fi + + OPENCLAW_GATEWAY_OUTPUT="$(run_openclaw_checked "openclaw gateway start" openclaw gateway start)" + OPENCLAW_GATEWAY_ACTION="started" +} + +wrap_openclaw_host() { + local plugin_path plugin_spec skip_build copy_mode proxy_port startup_timeout_ms python_path + local no_auto_start no_restart verbose + local gateway_provider_ids=() + + parse_openclaw_wrap_args \ + plugin_path \ + plugin_spec \ + skip_build \ + copy_mode \ + proxy_port \ + startup_timeout_ms \ + gateway_provider_ids \ + python_path \ + no_auto_start \ + no_restart \ + verbose \ + "$@" + + require_cmd openclaw + local existing_entry_json="" + existing_entry_json="$(get_openclaw_existing_entry_json)" + local entry_json + entry_json="$(prepare_openclaw_entry_json "${existing_entry_json}" "${proxy_port}" "${startup_timeout_ms}" "${python_path}" "${no_auto_start}" "${gateway_provider_ids[@]}")" + + printf '\n ╔═══════════════════════════════════════════════╗\n' + printf ' ║ HEADROOM WRAP: OPENCLAW ║\n' + printf ' ╚═══════════════════════════════════════════════╝\n\n' + if [[ -n "${plugin_path}" ]]; then + printf ' Plugin source: local (%s)\n' "${plugin_path}" + else + printf ' Plugin source: npm (%s)\n' "${plugin_spec}" + fi + + printf ' Writing plugin configuration...\n' + run_openclaw_checked \ + "openclaw config set plugins.entries.headroom" \ + openclaw config set plugins.entries.headroom "${entry_json}" --strict-json >/dev/null + + printf ' Installing OpenClaw plugin with required unsafe-install flag...\n' + install_openclaw_plugin "${plugin_path}" "${plugin_spec}" "${skip_build}" "${copy_mode}" "${verbose}" + + run_openclaw_checked \ + "openclaw config set plugins.slots.contextEngine" \ + openclaw config set plugins.slots.contextEngine '"headroom"' --strict-json >/dev/null + run_openclaw_checked "openclaw config validate" openclaw config validate >/dev/null + + if [[ "${no_restart}" -eq 1 ]]; then + printf ' Skipping gateway restart (--no-restart).\n' + printf ' Run `openclaw gateway restart` (or `openclaw gateway start`) to apply plugin changes.\n' + else + printf ' Applying plugin changes to OpenClaw gateway...\n' + restart_or_start_openclaw_gateway + printf ' Gateway %s.\n' "${OPENCLAW_GATEWAY_ACTION}" + if [[ "${verbose}" -eq 1 && -n "${OPENCLAW_GATEWAY_OUTPUT}" ]]; then + printf '%s\n' "${OPENCLAW_GATEWAY_OUTPUT}" + fi + fi + + local inspect_output="" + inspect_output="$(run_openclaw_checked "openclaw plugins inspect headroom" openclaw plugins inspect headroom)" + if [[ "${verbose}" -eq 1 && -n "${inspect_output}" ]]; then + printf '%s\n' "${inspect_output}" + fi + + printf '\n✓ OpenClaw is configured to use Headroom context compression.\n' + printf ' Plugin: headroom\n' + printf ' Slot: plugins.slots.contextEngine = headroom\n\n' +} + +unwrap_openclaw_host() { + local no_restart verbose + parse_openclaw_unwrap_args no_restart verbose "$@" + + require_cmd openclaw + local existing_entry_json="" + existing_entry_json="$(get_openclaw_existing_entry_json)" + local entry_json + entry_json="$(prepare_openclaw_unwrap_entry_json "${existing_entry_json}")" + + printf '\n ╔═══════════════════════════════════════════════╗\n' + printf ' ║ HEADROOM UNWRAP: OPENCLAW ║\n' + printf ' ╚═══════════════════════════════════════════════╝\n\n' + printf ' Disabling Headroom plugin and removing engine mapping...\n' + + run_openclaw_checked \ + "openclaw config set plugins.entries.headroom" \ + openclaw config set plugins.entries.headroom "${entry_json}" --strict-json >/dev/null + run_openclaw_checked \ + "openclaw config set plugins.slots.contextEngine" \ + openclaw config set plugins.slots.contextEngine '"legacy"' --strict-json >/dev/null + run_openclaw_checked "openclaw config validate" openclaw config validate >/dev/null + + if [[ "${no_restart}" -eq 1 ]]; then + printf ' Skipping gateway restart (--no-restart).\n' + printf ' Run `openclaw gateway restart` (or `openclaw gateway start`) to apply unwrap changes.\n' + else + printf ' Applying unwrap changes to OpenClaw gateway...\n' + restart_or_start_openclaw_gateway + printf ' Gateway %s.\n' "${OPENCLAW_GATEWAY_ACTION}" + if [[ "${verbose}" -eq 1 && -n "${OPENCLAW_GATEWAY_OUTPUT}" ]]; then + printf '%s\n' "${OPENCLAW_GATEWAY_OUTPUT}" + fi + fi + + if [[ "${verbose}" -eq 1 ]]; then + local inspect_output="" + inspect_output="$(run_openclaw_checked "openclaw plugins inspect headroom" openclaw plugins inspect headroom)" + if [[ -n "${inspect_output}" ]]; then + printf '%s\n' "${inspect_output}" + fi + fi + + printf '\n✓ OpenClaw Headroom wrap removed.\n' + printf ' Plugin: headroom (installed, disabled)\n' + printf ' Slot: plugins.slots.contextEngine = legacy\n\n' +} + main() { require_cmd docker @@ -348,10 +793,19 @@ main() { case "$1" in wrap) - (($# >= 2)) || die "Usage: headroom wrap [...]" + (($# >= 2)) || die "Usage: headroom wrap [...]" local tool="$2" shift 2 + if [[ "${tool}" == "openclaw" ]]; then + if contains_help_flag "$@"; then + run_headroom wrap openclaw "$@" + return + fi + wrap_openclaw_host "$@" + return + fi + local known_args host_args port no_rtk no_proxy learn backend anyllm region parse_wrap_args known_args host_args port no_rtk no_proxy learn backend anyllm region "$@" @@ -372,9 +826,6 @@ main() { case "${tool}" in claude|codex|aider|cursor) ;; - openclaw) - die "Docker-native install does not support 'headroom wrap openclaw' yet. Use a native Headroom install for OpenClaw plugin management." - ;; *) die "Unsupported wrap target: ${tool}" ;; @@ -424,7 +875,13 @@ EOF ;; unwrap) if (($# >= 2)) && [[ "$2" == "openclaw" ]]; then - die "Docker-native install does not support 'headroom unwrap openclaw' yet. Use a native Headroom install for OpenClaw plugin management." + shift 2 + if contains_help_flag "$@"; then + run_headroom unwrap openclaw "$@" + return + fi + unwrap_openclaw_host "$@" + return fi run_headroom "$@" ;; diff --git a/tests/test_cli/test_wrap_bridge.py b/tests/test_cli/test_wrap_bridge.py index b60700c3a..48c475f38 100644 --- a/tests/test_cli/test_wrap_bridge.py +++ b/tests/test_cli/test_wrap_bridge.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from pathlib import Path from unittest.mock import patch @@ -68,3 +69,56 @@ def test_wrap_cursor_prepare_only_injects_cursorrules(monkeypatch, tmp_path: Pat cursorrules = Path(".cursorrules") assert cursorrules.exists() assert "headroom:rtk-instructions" in cursorrules.read_text() + + +def test_wrap_openclaw_prepare_only_emits_config_without_python_default() -> None: + runner = CliRunner() + + result = runner.invoke( + main, + [ + "wrap", + "openclaw", + "--prepare-only", + "--gateway-provider-id", + "codex", + "--gateway-provider-id", + "anthropic", + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload["enabled"] is True + assert payload["config"]["proxyPort"] == 8787 + assert payload["config"]["gatewayProviderIds"] == ["codex", "anthropic"] + assert "pythonPath" not in payload["config"] + + +def test_unwrap_openclaw_prepare_only_preserves_unmanaged_config() -> None: + runner = CliRunner() + existing_entry = json.dumps( + { + "enabled": True, + "config": { + "pythonPath": "C:\\Python312\\python.exe", + "proxyPort": 8787, + "customFlag": True, + }, + } + ) + + result = runner.invoke( + main, + [ + "unwrap", + "openclaw", + "--prepare-only", + "--existing-entry-json", + existing_entry, + ], + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + assert payload == {"enabled": False, "config": {"customFlag": True}} diff --git a/tests/test_cli/test_wrap_openclaw.py b/tests/test_cli/test_wrap_openclaw.py index c600c57e8..a880ff8de 100644 --- a/tests/test_cli/test_wrap_openclaw.py +++ b/tests/test_cli/test_wrap_openclaw.py @@ -389,6 +389,24 @@ def test_build_openclaw_plugin_entry_sets_and_clears_python_path() -> None: assert without_python["config"]["customFlag"] is True +def test_build_openclaw_unwrap_entry_preserves_top_level_metadata() -> None: + entry = wrap_cli._build_openclaw_unwrap_entry( + { + "source": "headroom-ai/openclaw", + "enabled": True, + "config": { + "pythonPath": "C:\\Python312\\python.exe", + "proxyPort": 8787, + "customFlag": True, + }, + } + ) + + assert entry["source"] == "headroom-ai/openclaw" + assert entry["enabled"] is False + assert entry["config"] == {"customFlag": True} + + def test_wrap_openclaw_no_auto_start_does_not_default_python_path( runner: CliRunner, plugin_dir: Path ) -> None: