JerrettDavis Copilot commited on
Commit
2c747d4
·
1 Parent(s): 5c747ec

feat(cli): support OpenClaw in Docker-native installs

Browse files

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 CHANGED
@@ -153,7 +153,7 @@ headroom wrap codex --memory # Shares the same memory store
153
 
154
  Headroom starts a proxy, points your tool at it, and compresses everything automatically. Add `--memory` for persistent memory that's shared across agents.
155
 
156
- 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.
157
 
158
  ### Multi-agent — SharedContext
159
 
 
153
 
154
  Headroom starts a proxy, points your tool at it, and compresses everything automatically. Add `--memory` for persistent memory that's shared across agents.
155
 
156
+ 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.
157
 
158
  ### Multi-agent — SharedContext
159
 
docs/cli.md CHANGED
@@ -32,8 +32,8 @@ This page is the authoritative reference for the **Python Headroom CLI** exposed
32
  | `headroom wrap codex` | Start proxy and launch Codex CLI | **host-bridged** |
33
  | `headroom wrap aider` | Start proxy and launch Aider | **host-bridged** |
34
  | `headroom wrap cursor` | Start proxy and print Cursor config guidance | **host-bridged** |
35
- | `headroom wrap openclaw` | Install and configure the OpenClaw plugin | **unsupported in Docker-native mode** |
36
- | `headroom unwrap openclaw` | Disable the Headroom OpenClaw plugin | **unsupported in Docker-native mode** |
37
 
38
  ## Captured `--help` output
39
 
@@ -650,7 +650,7 @@ headroom wrap openclaw --plugin-path ./plugins/openclaw
650
  | `--no-restart` | off | Do not restart the OpenClaw gateway |
651
  | `--verbose`, `-v` | off | Verbose output |
652
 
653
- Requires the `openclaw` binary on the host, and local-source mode may also require `npm`.
654
 
655
  ## `headroom unwrap`
656
 
@@ -678,8 +678,6 @@ Legend:
678
 
679
  - **native in container** — the command runs entirely inside the Headroom container
680
  - **host-bridged** — Headroom runs in Docker, but the wrapped external tool still runs on the host
681
- - **unsupported** — use a native Python install of Headroom
682
-
683
  | Command path | Python CLI | Docker-native wrapper | Parity |
684
  |---|---|---|---|
685
  | `headroom proxy` | native | native in container | full |
@@ -696,8 +694,8 @@ Legend:
696
  | `headroom wrap codex` | native | host-bridged | partial |
697
  | `headroom wrap aider` | native | host-bridged | partial |
698
  | `headroom wrap cursor` | native | host-bridged | partial |
699
- | `headroom wrap openclaw` | native | unsupported | none |
700
- | `headroom unwrap openclaw` | native | unsupported | none |
701
 
702
  For the Docker-native execution model itself, see [Docker-Native Install](docker-install.md).
703
 
 
32
  | `headroom wrap codex` | Start proxy and launch Codex CLI | **host-bridged** |
33
  | `headroom wrap aider` | Start proxy and launch Aider | **host-bridged** |
34
  | `headroom wrap cursor` | Start proxy and print Cursor config guidance | **host-bridged** |
35
+ | `headroom wrap openclaw` | Install and configure the OpenClaw plugin | **host-bridged** |
36
+ | `headroom unwrap openclaw` | Disable the Headroom OpenClaw plugin | **host-bridged** |
37
 
38
  ## Captured `--help` output
39
 
 
650
  | `--no-restart` | off | Do not restart the OpenClaw gateway |
651
  | `--verbose`, `-v` | off | Verbose output |
652
 
653
+ 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`.
654
 
655
  ## `headroom unwrap`
656
 
 
678
 
679
  - **native in container** — the command runs entirely inside the Headroom container
680
  - **host-bridged** — Headroom runs in Docker, but the wrapped external tool still runs on the host
 
 
681
  | Command path | Python CLI | Docker-native wrapper | Parity |
682
  |---|---|---|---|
683
  | `headroom proxy` | native | native in container | full |
 
694
  | `headroom wrap codex` | native | host-bridged | partial |
695
  | `headroom wrap aider` | native | host-bridged | partial |
696
  | `headroom wrap cursor` | native | host-bridged | partial |
697
+ | `headroom wrap openclaw` | native | host-bridged | partial |
698
+ | `headroom unwrap openclaw` | native | host-bridged | partial |
699
 
700
  For the Docker-native execution model itself, see [Docker-Native Install](docker-install.md).
701
 
docs/docker-install.md CHANGED
@@ -71,13 +71,15 @@ Supported host wrap flows:
71
  - `headroom wrap codex`
72
  - `headroom wrap aider`
73
  - `headroom wrap cursor`
74
-
75
- Not yet supported in Docker-native mode:
76
-
77
  - `headroom wrap openclaw`
78
  - `headroom unwrap openclaw`
79
 
80
- Those still require a native Headroom install because OpenClaw plugin install and gateway lifecycle management are host-native operations.
 
 
 
 
 
81
 
82
  ## Docker Compose support
83
 
 
71
  - `headroom wrap codex`
72
  - `headroom wrap aider`
73
  - `headroom wrap cursor`
 
 
 
74
  - `headroom wrap openclaw`
75
  - `headroom unwrap openclaw`
76
 
77
+ OpenClaw remains host-native in Docker-native mode:
78
+
79
+ - the host must already have the `openclaw` CLI installed
80
+ - `headroom wrap openclaw` installs/configures the Headroom plugin through the host `openclaw` CLI
81
+ - plugin auto-start still launches the installed host `headroom` wrapper from `PATH`, which then runs Headroom in Docker
82
+ - local plugin source mode (`--plugin-path`) is also supported, but it may require host `npm` when build steps are needed
83
 
84
  ## Docker Compose support
85
 
docs/integration-guide.md CHANGED
@@ -270,8 +270,7 @@ See the [TypeScript SDK Guide](typescript-sdk.md) for full documentation includi
270
  Context compression plugin for [OpenClaw](https://github.com/openclaw/openclaw) agents.
271
 
272
  ```bash
273
- pip install "headroom-ai[proxy]"
274
- openclaw plugins install headroom-openclaw
275
  ```
276
 
277
  Configure as context engine:
@@ -279,6 +278,13 @@ Configure as context engine:
279
  { "plugins": { "slots": { "contextEngine": "headroom" } } }
280
  ```
281
 
 
 
 
 
 
 
 
282
  The plugin auto-detects a running Headroom proxy or starts one. Compression happens in `assemble()` — zero changes to the agent's behavior.
283
 
284
  See the [OpenClaw plugin documentation](https://github.com/chopratejas/headroom/tree/main/plugins/openclaw) for full setup.
 
270
  Context compression plugin for [OpenClaw](https://github.com/openclaw/openclaw) agents.
271
 
272
  ```bash
273
+ headroom wrap openclaw
 
274
  ```
275
 
276
  Configure as context engine:
 
278
  { "plugins": { "slots": { "contextEngine": "headroom" } } }
279
  ```
280
 
281
+ Manual install remains available when you are not using the CLI wrapper:
282
+
283
+ ```bash
284
+ pip install "headroom-ai[proxy]"
285
+ openclaw plugins install --dangerously-force-unsafe-install headroom-ai/openclaw
286
+ ```
287
+
288
  The plugin auto-detects a running Headroom proxy or starts one. Compression happens in `assemble()` — zero changes to the agent's behavior.
289
 
290
  See the [OpenClaw plugin documentation](https://github.com/chopratejas/headroom/tree/main/plugins/openclaw) for full setup.
docs/typescript-sdk.md CHANGED
@@ -200,7 +200,7 @@ The `headroom-ai` package has no runtime dependencies. Framework SDKs (Vercel AI
200
 
201
  ## OpenClaw Plugin
202
 
203
- 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.
204
 
205
  ## Comparison with Python SDK
206
 
 
200
 
201
  ## OpenClaw Plugin
202
 
203
+ 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.
204
 
205
  ## Comparison with Python SDK
206
 
headroom/cli/wrap.py CHANGED
@@ -549,6 +549,17 @@ def _read_openclaw_config_value(openclaw_bin: str, path: str) -> Any | None:
549
  return output
550
 
551
 
 
 
 
 
 
 
 
 
 
 
 
552
  def _build_openclaw_plugin_entry(
553
  *,
554
  existing_entry: Any,
@@ -583,6 +594,28 @@ def _build_openclaw_plugin_entry(
583
  }
584
 
585
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
586
  def _write_openclaw_plugin_entry(openclaw_bin: str, entry: dict[str, object]) -> None:
587
  """Persist the Headroom plugin config entry."""
588
  _run_checked(
@@ -1132,6 +1165,8 @@ def cursor(
1132
  help="Do not restart OpenClaw gateway at the end",
1133
  )
1134
  @click.option("--verbose", "-v", is_flag=True, help="Verbose output")
 
 
1135
  def openclaw(
1136
  plugin_path: Path | None,
1137
  plugin_spec: str,
@@ -1144,6 +1179,8 @@ def openclaw(
1144
  no_auto_start: bool,
1145
  no_restart: bool,
1146
  verbose: bool,
 
 
1147
  ) -> None:
1148
  """Install and configure Headroom OpenClaw plugin in one command.
1149
 
@@ -1160,6 +1197,19 @@ def openclaw(
1160
  headroom wrap openclaw
1161
  headroom wrap openclaw --plugin-path C:\\git\\headroom\\plugins\\openclaw
1162
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
1163
  openclaw_bin = shutil.which("openclaw")
1164
  if not openclaw_bin:
1165
  raise click.ClickException("'openclaw' not found in PATH. Install OpenClaw CLI first.")
@@ -1304,8 +1354,24 @@ def openclaw(
1304
  @unwrap.command("openclaw")
1305
  @click.option("--no-restart", is_flag=True, help="Do not restart OpenClaw gateway at the end")
1306
  @click.option("--verbose", "-v", is_flag=True, help="Verbose output")
1307
- def unwrap_openclaw(no_restart: bool, verbose: bool) -> None:
 
 
 
 
 
 
 
1308
  """Disable the Headroom OpenClaw plugin and restore the legacy engine slot."""
 
 
 
 
 
 
 
 
 
1309
  openclaw_bin = shutil.which("openclaw")
1310
  if not openclaw_bin:
1311
  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:
1318
  click.echo(" Disabling Headroom plugin and removing engine mapping...")
1319
 
1320
  existing_entry = _read_openclaw_config_value(openclaw_bin, "plugins.entries.headroom")
1321
- existing_config = {}
1322
- if isinstance(existing_entry, dict) and isinstance(existing_entry.get("config"), dict):
1323
- existing_config = {
1324
- key: value
1325
- for key, value in existing_entry["config"].items()
1326
- if key
1327
- not in {
1328
- "gatewayProviderIds",
1329
- "proxyUrl",
1330
- "proxyPort",
1331
- "autoStart",
1332
- "startupTimeoutMs",
1333
- "pythonPath",
1334
- }
1335
- }
1336
-
1337
- entry = {"enabled": False, "config": existing_config}
1338
  _write_openclaw_plugin_entry(openclaw_bin, entry)
1339
  _set_openclaw_context_engine_slot(openclaw_bin, "legacy")
1340
  _run_checked(
 
549
  return output
550
 
551
 
552
+ def _decode_openclaw_entry_json(raw_value: str | None) -> Any | None:
553
+ """Decode a JSON payload captured from `openclaw config get` when available."""
554
+ if not raw_value:
555
+ return None
556
+
557
+ try:
558
+ return json.loads(raw_value)
559
+ except json.JSONDecodeError:
560
+ return raw_value
561
+
562
+
563
  def _build_openclaw_plugin_entry(
564
  *,
565
  existing_entry: Any,
 
594
  }
595
 
596
 
597
+ def _build_openclaw_unwrap_entry(existing_entry: Any) -> dict[str, object]:
598
+ """Disable the managed plugin while preserving unrelated user config."""
599
+ base_entry = existing_entry if isinstance(existing_entry, dict) else {}
600
+ existing_config = {}
601
+ if isinstance(existing_entry, dict) and isinstance(existing_entry.get("config"), dict):
602
+ existing_config = {
603
+ key: value
604
+ for key, value in existing_entry["config"].items()
605
+ if key
606
+ not in {
607
+ "gatewayProviderIds",
608
+ "proxyUrl",
609
+ "proxyPort",
610
+ "autoStart",
611
+ "startupTimeoutMs",
612
+ "pythonPath",
613
+ }
614
+ }
615
+
616
+ return {**base_entry, "enabled": False, "config": existing_config}
617
+
618
+
619
  def _write_openclaw_plugin_entry(openclaw_bin: str, entry: dict[str, object]) -> None:
620
  """Persist the Headroom plugin config entry."""
621
  _run_checked(
 
1165
  help="Do not restart OpenClaw gateway at the end",
1166
  )
1167
  @click.option("--verbose", "-v", is_flag=True, help="Verbose output")
1168
+ @click.option("--prepare-only", is_flag=True, hidden=True)
1169
+ @click.option("--existing-entry-json", default=None, hidden=True)
1170
  def openclaw(
1171
  plugin_path: Path | None,
1172
  plugin_spec: str,
 
1179
  no_auto_start: bool,
1180
  no_restart: bool,
1181
  verbose: bool,
1182
+ prepare_only: bool,
1183
+ existing_entry_json: str | None,
1184
  ) -> None:
1185
  """Install and configure Headroom OpenClaw plugin in one command.
1186
 
 
1197
  headroom wrap openclaw
1198
  headroom wrap openclaw --plugin-path C:\\git\\headroom\\plugins\\openclaw
1199
  """
1200
+ if prepare_only:
1201
+ entry = _build_openclaw_plugin_entry(
1202
+ existing_entry=_decode_openclaw_entry_json(existing_entry_json),
1203
+ proxy_port=proxy_port,
1204
+ startup_timeout_ms=startup_timeout_ms,
1205
+ python_path=python_path,
1206
+ no_auto_start=no_auto_start,
1207
+ gateway_provider_ids=gateway_provider_ids,
1208
+ enabled=True,
1209
+ )
1210
+ click.echo(json.dumps(entry, separators=(",", ":")))
1211
+ return
1212
+
1213
  openclaw_bin = shutil.which("openclaw")
1214
  if not openclaw_bin:
1215
  raise click.ClickException("'openclaw' not found in PATH. Install OpenClaw CLI first.")
 
1354
  @unwrap.command("openclaw")
1355
  @click.option("--no-restart", is_flag=True, help="Do not restart OpenClaw gateway at the end")
1356
  @click.option("--verbose", "-v", is_flag=True, help="Verbose output")
1357
+ @click.option("--prepare-only", is_flag=True, hidden=True)
1358
+ @click.option("--existing-entry-json", default=None, hidden=True)
1359
+ def unwrap_openclaw(
1360
+ no_restart: bool,
1361
+ verbose: bool,
1362
+ prepare_only: bool,
1363
+ existing_entry_json: str | None,
1364
+ ) -> None:
1365
  """Disable the Headroom OpenClaw plugin and restore the legacy engine slot."""
1366
+ if prepare_only:
1367
+ click.echo(
1368
+ json.dumps(
1369
+ _build_openclaw_unwrap_entry(_decode_openclaw_entry_json(existing_entry_json)),
1370
+ separators=(",", ":"),
1371
+ )
1372
+ )
1373
+ return
1374
+
1375
  openclaw_bin = shutil.which("openclaw")
1376
  if not openclaw_bin:
1377
  raise click.ClickException("'openclaw' not found in PATH. Install OpenClaw CLI first.")
 
1384
  click.echo(" Disabling Headroom plugin and removing engine mapping...")
1385
 
1386
  existing_entry = _read_openclaw_config_value(openclaw_bin, "plugins.entries.headroom")
1387
+ entry = _build_openclaw_unwrap_entry(existing_entry)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1388
  _write_openclaw_plugin_entry(openclaw_bin, entry)
1389
  _set_openclaw_context_engine_slot(openclaw_bin, "legacy")
1390
  _run_checked(
plugins/openclaw/README.md CHANGED
@@ -150,6 +150,8 @@ When `proxyUrl` points to localhost (or is omitted), the plugin will auto-start
150
 
151
  If `pythonPath` is set, it is tried first in the Python fallback step.
152
 
 
 
153
  ### Remote proxy (connect-only)
154
 
155
  Point `proxyUrl` to any reachable Headroom instance:
 
150
 
151
  If `pythonPath` is set, it is tried first in the Python fallback step.
152
 
153
+ 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.
154
+
155
  ### Remote proxy (connect-only)
156
 
157
  Point `proxyUrl` to any reachable Headroom instance:
scripts/install.ps1 CHANGED
@@ -265,6 +265,496 @@ function Invoke-WithTemporaryEnv {
265
  }
266
  }
267
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
  function Parse-WrapArgs {
269
  param([string[]]$Arguments)
270
 
@@ -421,11 +911,23 @@ if ($args.Count -eq 0) {
421
  switch ($args[0]) {
422
  'wrap' {
423
  if ($args.Count -lt 2) {
424
- Fail 'Usage: headroom wrap <claude|codex|aider|cursor> [...]'
425
  }
426
 
427
  $tool = $args[1]
428
  $wrapArgs = if ($args.Count -gt 2) { $args[2..($args.Count - 1)] } else { @() }
 
 
 
 
 
 
 
 
 
 
 
 
429
  $parsed = Parse-WrapArgs -Arguments $wrapArgs
430
  $proxyArgs = New-Object System.Collections.Generic.List[string]
431
  if ($parsed.Learn) { $proxyArgs.Add('--learn') }
@@ -438,7 +940,6 @@ switch ($args[0]) {
438
  'codex' { }
439
  'aider' { }
440
  'cursor' { }
441
- 'openclaw' { Fail "Docker-native install does not support 'headroom wrap openclaw' yet. Use a native Headroom install for OpenClaw plugin management." }
442
  default { Fail "Unsupported wrap target: $tool" }
443
  }
444
 
@@ -490,7 +991,15 @@ switch ($args[0]) {
490
  }
491
  'unwrap' {
492
  if ($args.Count -ge 2 -and $args[1] -eq 'openclaw') {
493
- Fail "Docker-native install does not support 'headroom unwrap openclaw' yet. Use a native Headroom install for OpenClaw plugin management."
 
 
 
 
 
 
 
 
494
  }
495
  Invoke-HeadroomDocker -Arguments $args
496
  }
 
265
  }
266
  }
267
 
268
+ function Test-HelpFlag {
269
+ param([string[]]$Arguments)
270
+
271
+ foreach ($arg in $Arguments) {
272
+ if ($arg -eq '--help' -or $arg -eq '-?') {
273
+ return $true
274
+ }
275
+ }
276
+
277
+ return $false
278
+ }
279
+
280
+ function Parse-OpenClawWrapArgs {
281
+ param([string[]]$Arguments)
282
+
283
+ $gatewayProviderIds = New-Object System.Collections.Generic.List[string]
284
+ $pluginPath = $null
285
+ $pluginSpec = 'headroom-ai/openclaw'
286
+ $skipBuild = $false
287
+ $copy = $false
288
+ $proxyPort = 8787
289
+ $startupTimeoutMs = 20000
290
+ $pythonPath = $null
291
+ $noAutoStart = $false
292
+ $noRestart = $false
293
+ $verbose = $false
294
+
295
+ $i = 0
296
+ while ($i -lt $Arguments.Count) {
297
+ $arg = $Arguments[$i]
298
+ switch -Regex ($arg) {
299
+ '^--plugin-path$' {
300
+ $pluginPath = $Arguments[$i + 1]
301
+ $i += 2
302
+ continue
303
+ }
304
+ '^--plugin-path=' {
305
+ $pluginPath = $arg -replace '^--plugin-path=', ''
306
+ $i += 1
307
+ continue
308
+ }
309
+ '^--plugin-spec$' {
310
+ $pluginSpec = $Arguments[$i + 1]
311
+ $i += 2
312
+ continue
313
+ }
314
+ '^--plugin-spec=' {
315
+ $pluginSpec = $arg -replace '^--plugin-spec=', ''
316
+ $i += 1
317
+ continue
318
+ }
319
+ '^--skip-build$' {
320
+ $skipBuild = $true
321
+ $i += 1
322
+ continue
323
+ }
324
+ '^--copy$' {
325
+ $copy = $true
326
+ $i += 1
327
+ continue
328
+ }
329
+ '^--proxy-port$' {
330
+ $proxyPort = [int]$Arguments[$i + 1]
331
+ $i += 2
332
+ continue
333
+ }
334
+ '^--proxy-port=' {
335
+ $proxyPort = [int]($arg -replace '^--proxy-port=', '')
336
+ $i += 1
337
+ continue
338
+ }
339
+ '^--startup-timeout-ms$' {
340
+ $startupTimeoutMs = [int]$Arguments[$i + 1]
341
+ $i += 2
342
+ continue
343
+ }
344
+ '^--startup-timeout-ms=' {
345
+ $startupTimeoutMs = [int]($arg -replace '^--startup-timeout-ms=', '')
346
+ $i += 1
347
+ continue
348
+ }
349
+ '^--gateway-provider-id$' {
350
+ $gatewayProviderIds.Add($Arguments[$i + 1])
351
+ $i += 2
352
+ continue
353
+ }
354
+ '^--gateway-provider-id=' {
355
+ $gatewayProviderIds.Add($arg -replace '^--gateway-provider-id=', '')
356
+ $i += 1
357
+ continue
358
+ }
359
+ '^--python-path$' {
360
+ $pythonPath = $Arguments[$i + 1]
361
+ $i += 2
362
+ continue
363
+ }
364
+ '^--python-path=' {
365
+ $pythonPath = $arg -replace '^--python-path=', ''
366
+ $i += 1
367
+ continue
368
+ }
369
+ '^--no-auto-start$' {
370
+ $noAutoStart = $true
371
+ $i += 1
372
+ continue
373
+ }
374
+ '^--no-restart$' {
375
+ $noRestart = $true
376
+ $i += 1
377
+ continue
378
+ }
379
+ '^--verbose$|^-v$' {
380
+ $verbose = $true
381
+ $i += 1
382
+ continue
383
+ }
384
+ default {
385
+ Fail "Unsupported option for 'headroom wrap openclaw': $arg"
386
+ }
387
+ }
388
+ }
389
+
390
+ [pscustomobject]@{
391
+ PluginPath = $pluginPath
392
+ PluginSpec = $pluginSpec
393
+ SkipBuild = $skipBuild
394
+ Copy = $copy
395
+ ProxyPort = $proxyPort
396
+ StartupTimeoutMs = $startupTimeoutMs
397
+ GatewayProviderIds = $gatewayProviderIds.ToArray()
398
+ PythonPath = $pythonPath
399
+ NoAutoStart = $noAutoStart
400
+ NoRestart = $noRestart
401
+ Verbose = $verbose
402
+ }
403
+ }
404
+
405
+ function Parse-OpenClawUnwrapArgs {
406
+ param([string[]]$Arguments)
407
+
408
+ $noRestart = $false
409
+ $verbose = $false
410
+ $i = 0
411
+ while ($i -lt $Arguments.Count) {
412
+ $arg = $Arguments[$i]
413
+ switch -Regex ($arg) {
414
+ '^--no-restart$' {
415
+ $noRestart = $true
416
+ $i += 1
417
+ continue
418
+ }
419
+ '^--verbose$|^-v$' {
420
+ $verbose = $true
421
+ $i += 1
422
+ continue
423
+ }
424
+ default {
425
+ Fail "Unsupported option for 'headroom unwrap openclaw': $arg"
426
+ }
427
+ }
428
+ }
429
+
430
+ [pscustomobject]@{
431
+ NoRestart = $noRestart
432
+ Verbose = $verbose
433
+ }
434
+ }
435
+
436
+ function Invoke-CapturedCommand {
437
+ param(
438
+ [string]$Action,
439
+ [string]$Command,
440
+ [string[]]$Arguments,
441
+ [string]$WorkingDirectory
442
+ )
443
+
444
+ $previous = $null
445
+ try {
446
+ if ($WorkingDirectory) {
447
+ $previous = Get-Location
448
+ Set-Location $WorkingDirectory
449
+ }
450
+
451
+ $output = (& $Command @Arguments 2>&1 | Out-String).Trim()
452
+ $exitCode = $LASTEXITCODE
453
+ } finally {
454
+ if ($previous) {
455
+ Set-Location $previous
456
+ }
457
+ }
458
+
459
+ if ($exitCode -ne 0) {
460
+ if (-not $output) {
461
+ $output = "exit code $exitCode"
462
+ }
463
+ Fail "$Action failed: $output"
464
+ }
465
+
466
+ return $output
467
+ }
468
+
469
+ function Get-OpenClawExistingEntryJson {
470
+ $output = (& openclaw config get plugins.entries.headroom 2>$null | Out-String).Trim()
471
+ if ($LASTEXITCODE -ne 0) {
472
+ return $null
473
+ }
474
+ return $output
475
+ }
476
+
477
+ function Invoke-OpenClawPrepareEntryJson {
478
+ param(
479
+ [string]$ExistingEntryJson,
480
+ [pscustomobject]$Parsed
481
+ )
482
+
483
+ $dockerArgs = New-Object System.Collections.Generic.List[string]
484
+ $dockerArgs.AddRange(@('run','--rm'))
485
+ $dockerArgs.AddRange((Get-SharedDockerArgs))
486
+ $dockerArgs.Add('--entrypoint')
487
+ $dockerArgs.Add('headroom')
488
+ $dockerArgs.Add($HeadroomImage)
489
+ $dockerArgs.AddRange(@('wrap','openclaw','--prepare-only','--proxy-port',"$($Parsed.ProxyPort)",'--startup-timeout-ms',"$($Parsed.StartupTimeoutMs)"))
490
+ if ($ExistingEntryJson) {
491
+ $dockerArgs.Add('--existing-entry-json')
492
+ $dockerArgs.Add($ExistingEntryJson)
493
+ }
494
+ if ($Parsed.PythonPath) {
495
+ $dockerArgs.Add('--python-path')
496
+ $dockerArgs.Add($Parsed.PythonPath)
497
+ }
498
+ if ($Parsed.NoAutoStart) {
499
+ $dockerArgs.Add('--no-auto-start')
500
+ }
501
+ foreach ($providerId in $Parsed.GatewayProviderIds) {
502
+ $dockerArgs.Add('--gateway-provider-id')
503
+ $dockerArgs.Add($providerId)
504
+ }
505
+
506
+ $output = (& docker @dockerArgs 2>&1 | Out-String).Trim()
507
+ if ($LASTEXITCODE -ne 0) {
508
+ Fail "Failed to prepare docker-native OpenClaw config: $output"
509
+ }
510
+
511
+ return $output
512
+ }
513
+
514
+ function Invoke-OpenClawPrepareUnwrapEntryJson {
515
+ param([string]$ExistingEntryJson)
516
+
517
+ $dockerArgs = New-Object System.Collections.Generic.List[string]
518
+ $dockerArgs.AddRange(@('run','--rm'))
519
+ $dockerArgs.AddRange((Get-SharedDockerArgs))
520
+ $dockerArgs.Add('--entrypoint')
521
+ $dockerArgs.Add('headroom')
522
+ $dockerArgs.Add($HeadroomImage)
523
+ $dockerArgs.AddRange(@('unwrap','openclaw','--prepare-only'))
524
+ if ($ExistingEntryJson) {
525
+ $dockerArgs.Add('--existing-entry-json')
526
+ $dockerArgs.Add($ExistingEntryJson)
527
+ }
528
+
529
+ $output = (& docker @dockerArgs 2>&1 | Out-String).Trim()
530
+ if ($LASTEXITCODE -ne 0) {
531
+ Fail "Failed to prepare docker-native OpenClaw unwrap config: $output"
532
+ }
533
+
534
+ return $output
535
+ }
536
+
537
+ function Resolve-OpenClawExtensionsDir {
538
+ $configOutput = Invoke-CapturedCommand -Action 'openclaw config file' -Command 'openclaw' -Arguments @('config','file')
539
+ $configPath = ($configOutput -split "`r?`n")[-1].Trim()
540
+ if (-not $configPath) {
541
+ Fail 'Unable to resolve OpenClaw config path.'
542
+ }
543
+ return (Join-Path (Split-Path -Parent $configPath) 'extensions')
544
+ }
545
+
546
+ function Copy-OpenClawPluginIntoExtensions {
547
+ param([string]$PluginPath)
548
+
549
+ $distDir = Join-Path $PluginPath 'dist'
550
+ $hookShimDir = Join-Path $PluginPath 'hook-shim'
551
+ if (-not (Test-Path $distDir)) {
552
+ Fail "Plugin dist folder missing at $distDir. Build the plugin first."
553
+ }
554
+ if (-not (Test-Path $hookShimDir)) {
555
+ Fail "Plugin hook-shim folder missing at $hookShimDir. Build the plugin first."
556
+ }
557
+
558
+ $extensionsDir = Resolve-OpenClawExtensionsDir
559
+ $targetDir = Join-Path $extensionsDir 'headroom'
560
+ $targetDist = Join-Path $targetDir 'dist'
561
+ $targetHookShim = Join-Path $targetDir 'hook-shim'
562
+ New-Item -ItemType Directory -Force -Path $targetDir | Out-Null
563
+ if (Test-Path $targetDist) { Remove-Item -Recurse -Force $targetDist }
564
+ if (Test-Path $targetHookShim) { Remove-Item -Recurse -Force $targetHookShim }
565
+ Copy-Item -Recurse -Force $distDir $targetDist
566
+ Copy-Item -Recurse -Force $hookShimDir $targetHookShim
567
+
568
+ foreach ($fileName in @('openclaw.plugin.json','package.json','README.md')) {
569
+ $source = Join-Path $PluginPath $fileName
570
+ if (Test-Path $source) {
571
+ Copy-Item -Force $source (Join-Path $targetDir $fileName)
572
+ }
573
+ }
574
+
575
+ return $targetDir
576
+ }
577
+
578
+ function Install-OpenClawPlugin {
579
+ param([pscustomobject]$Parsed)
580
+
581
+ if ($Parsed.PluginPath) {
582
+ if (-not (Test-Path $Parsed.PluginPath)) {
583
+ Fail "Plugin path not found: $($Parsed.PluginPath)."
584
+ }
585
+ if (-not (Test-Path (Join-Path $Parsed.PluginPath 'package.json'))) {
586
+ Fail "Invalid plugin path (missing package.json): $($Parsed.PluginPath)"
587
+ }
588
+ if (-not (Test-Path (Join-Path $Parsed.PluginPath 'openclaw.plugin.json'))) {
589
+ Fail "Invalid plugin path (missing openclaw.plugin.json): $($Parsed.PluginPath)"
590
+ }
591
+ }
592
+
593
+ if ($Parsed.PluginPath -and -not $Parsed.SkipBuild) {
594
+ Require-Command npm
595
+ Write-Host ' Building OpenClaw plugin (npm install + npm run build)...'
596
+ [void](Invoke-CapturedCommand -Action 'npm install' -Command 'npm' -Arguments @('install') -WorkingDirectory $Parsed.PluginPath)
597
+ [void](Invoke-CapturedCommand -Action 'npm run build' -Command 'npm' -Arguments @('run','build') -WorkingDirectory $Parsed.PluginPath)
598
+ }
599
+
600
+ if ($Parsed.PluginPath) {
601
+ if ($Parsed.Copy) {
602
+ $arguments = @('plugins','install','--dangerously-force-unsafe-install',$Parsed.PluginPath)
603
+ $workingDirectory = $null
604
+ } else {
605
+ $arguments = @('plugins','install','--dangerously-force-unsafe-install','--link','.')
606
+ $workingDirectory = $Parsed.PluginPath
607
+ }
608
+ } else {
609
+ $arguments = @('plugins','install','--dangerously-force-unsafe-install',$Parsed.PluginSpec)
610
+ $workingDirectory = $null
611
+ }
612
+
613
+ $previous = $null
614
+ try {
615
+ if ($workingDirectory) {
616
+ $previous = Get-Location
617
+ Set-Location $workingDirectory
618
+ }
619
+ $installOutput = (& openclaw @arguments 2>&1 | Out-String).Trim()
620
+ $installExitCode = $LASTEXITCODE
621
+ } finally {
622
+ if ($previous) {
623
+ Set-Location $previous
624
+ }
625
+ }
626
+
627
+ if ($installExitCode -eq 0) {
628
+ if ($Parsed.Verbose -and $installOutput) {
629
+ Write-Host $installOutput
630
+ }
631
+ return
632
+ }
633
+
634
+ $lowerOutput = $installOutput.ToLowerInvariant()
635
+ if ($lowerOutput.Contains('plugin already exists')) {
636
+ Write-Host ' Plugin already installed; continuing with configuration/update steps.'
637
+ return
638
+ }
639
+
640
+ if ($Parsed.PluginPath -and -not $Parsed.Copy -and $lowerOutput.Contains('also not a valid hook pack')) {
641
+ Write-Host ' OpenClaw linked-path install bug detected; applying extension-path fallback...'
642
+ $targetDir = Copy-OpenClawPluginIntoExtensions -PluginPath $Parsed.PluginPath
643
+ Write-Host " Fallback plugin copy completed: $targetDir"
644
+ return
645
+ }
646
+
647
+ if (-not $installOutput) {
648
+ $installOutput = "exit code $installExitCode"
649
+ }
650
+ Fail "openclaw plugins install failed: $installOutput"
651
+ }
652
+
653
+ function Restart-OrStartOpenClawGateway {
654
+ $restartOutput = (& openclaw gateway restart 2>&1 | Out-String).Trim()
655
+ if ($LASTEXITCODE -eq 0) {
656
+ return [pscustomobject]@{ Action = 'restarted'; Output = $restartOutput }
657
+ }
658
+
659
+ $startOutput = Invoke-CapturedCommand -Action 'openclaw gateway start' -Command 'openclaw' -Arguments @('gateway','start')
660
+ return [pscustomobject]@{ Action = 'started'; Output = $startOutput }
661
+ }
662
+
663
+ function Invoke-OpenClawWrap {
664
+ param([string[]]$Arguments)
665
+
666
+ Require-Command openclaw
667
+ $parsed = Parse-OpenClawWrapArgs -Arguments $Arguments
668
+ $existingEntryJson = Get-OpenClawExistingEntryJson
669
+ $entryJson = Invoke-OpenClawPrepareEntryJson -ExistingEntryJson $existingEntryJson -Parsed $parsed
670
+
671
+ Write-Host ""
672
+ Write-Host " ╔═══════════════════════════════════════════════╗"
673
+ Write-Host " ║ HEADROOM WRAP: OPENCLAW ║"
674
+ Write-Host " ╚═══════════════════════════════════════════════╝"
675
+ Write-Host ""
676
+ if ($parsed.PluginPath) {
677
+ Write-Host " Plugin source: local ($($parsed.PluginPath))"
678
+ } else {
679
+ Write-Host " Plugin source: npm ($($parsed.PluginSpec))"
680
+ }
681
+
682
+ Write-Host ' Writing plugin configuration...'
683
+ [void](Invoke-CapturedCommand -Action 'openclaw config set plugins.entries.headroom' -Command 'openclaw' -Arguments @('config','set','plugins.entries.headroom',$entryJson,'--strict-json'))
684
+ Write-Host ' Installing OpenClaw plugin with required unsafe-install flag...'
685
+ Install-OpenClawPlugin -Parsed $parsed
686
+ [void](Invoke-CapturedCommand -Action 'openclaw config set plugins.slots.contextEngine' -Command 'openclaw' -Arguments @('config','set','plugins.slots.contextEngine','"headroom"','--strict-json'))
687
+ [void](Invoke-CapturedCommand -Action 'openclaw config validate' -Command 'openclaw' -Arguments @('config','validate'))
688
+
689
+ if ($parsed.NoRestart) {
690
+ Write-Host ' Skipping gateway restart (--no-restart).'
691
+ Write-Host ' Run `openclaw gateway restart` (or `openclaw gateway start`) to apply plugin changes.'
692
+ } else {
693
+ Write-Host ' Applying plugin changes to OpenClaw gateway...'
694
+ $gatewayResult = Restart-OrStartOpenClawGateway
695
+ Write-Host " Gateway $($gatewayResult.Action)."
696
+ if ($parsed.Verbose -and $gatewayResult.Output) {
697
+ Write-Host $gatewayResult.Output
698
+ }
699
+ }
700
+
701
+ $inspectOutput = Invoke-CapturedCommand -Action 'openclaw plugins inspect headroom' -Command 'openclaw' -Arguments @('plugins','inspect','headroom')
702
+ if ($parsed.Verbose -and $inspectOutput) {
703
+ Write-Host $inspectOutput
704
+ }
705
+
706
+ Write-Host ""
707
+ Write-Host "✓ OpenClaw is configured to use Headroom context compression."
708
+ Write-Host " Plugin: headroom"
709
+ Write-Host " Slot: plugins.slots.contextEngine = headroom"
710
+ Write-Host ""
711
+ }
712
+
713
+ function Invoke-OpenClawUnwrap {
714
+ param([string[]]$Arguments)
715
+
716
+ Require-Command openclaw
717
+ $parsed = Parse-OpenClawUnwrapArgs -Arguments $Arguments
718
+ $existingEntryJson = Get-OpenClawExistingEntryJson
719
+ $entryJson = Invoke-OpenClawPrepareUnwrapEntryJson -ExistingEntryJson $existingEntryJson
720
+
721
+ Write-Host ""
722
+ Write-Host " ╔═══════════════════════════════════════════════╗"
723
+ Write-Host " ║ HEADROOM UNWRAP: OPENCLAW ║"
724
+ Write-Host " ╚═══════════════════════════════════════════════╝"
725
+ Write-Host ""
726
+ Write-Host ' Disabling Headroom plugin and removing engine mapping...'
727
+
728
+ [void](Invoke-CapturedCommand -Action 'openclaw config set plugins.entries.headroom' -Command 'openclaw' -Arguments @('config','set','plugins.entries.headroom',$entryJson,'--strict-json'))
729
+ [void](Invoke-CapturedCommand -Action 'openclaw config set plugins.slots.contextEngine' -Command 'openclaw' -Arguments @('config','set','plugins.slots.contextEngine','"legacy"','--strict-json'))
730
+ [void](Invoke-CapturedCommand -Action 'openclaw config validate' -Command 'openclaw' -Arguments @('config','validate'))
731
+
732
+ if ($parsed.NoRestart) {
733
+ Write-Host ' Skipping gateway restart (--no-restart).'
734
+ Write-Host ' Run `openclaw gateway restart` (or `openclaw gateway start`) to apply unwrap changes.'
735
+ } else {
736
+ Write-Host ' Applying unwrap changes to OpenClaw gateway...'
737
+ $gatewayResult = Restart-OrStartOpenClawGateway
738
+ Write-Host " Gateway $($gatewayResult.Action)."
739
+ if ($parsed.Verbose -and $gatewayResult.Output) {
740
+ Write-Host $gatewayResult.Output
741
+ }
742
+ }
743
+
744
+ if ($parsed.Verbose) {
745
+ $inspectOutput = Invoke-CapturedCommand -Action 'openclaw plugins inspect headroom' -Command 'openclaw' -Arguments @('plugins','inspect','headroom')
746
+ if ($inspectOutput) {
747
+ Write-Host $inspectOutput
748
+ }
749
+ }
750
+
751
+ Write-Host ""
752
+ Write-Host "✓ OpenClaw Headroom wrap removed."
753
+ Write-Host " Plugin: headroom (installed, disabled)"
754
+ Write-Host " Slot: plugins.slots.contextEngine = legacy"
755
+ Write-Host ""
756
+ }
757
+
758
  function Parse-WrapArgs {
759
  param([string[]]$Arguments)
760
 
 
911
  switch ($args[0]) {
912
  'wrap' {
913
  if ($args.Count -lt 2) {
914
+ Fail 'Usage: headroom wrap <claude|codex|aider|cursor|openclaw> [...]'
915
  }
916
 
917
  $tool = $args[1]
918
  $wrapArgs = if ($args.Count -gt 2) { $args[2..($args.Count - 1)] } else { @() }
919
+
920
+ if ($tool -eq 'openclaw') {
921
+ if (Test-HelpFlag -Arguments $wrapArgs) {
922
+ $helpArgs = @('wrap','openclaw') + $wrapArgs
923
+ Invoke-HeadroomDocker -Arguments $helpArgs
924
+ exit 0
925
+ }
926
+
927
+ Invoke-OpenClawWrap -Arguments $wrapArgs
928
+ exit 0
929
+ }
930
+
931
  $parsed = Parse-WrapArgs -Arguments $wrapArgs
932
  $proxyArgs = New-Object System.Collections.Generic.List[string]
933
  if ($parsed.Learn) { $proxyArgs.Add('--learn') }
 
940
  'codex' { }
941
  'aider' { }
942
  'cursor' { }
 
943
  default { Fail "Unsupported wrap target: $tool" }
944
  }
945
 
 
991
  }
992
  'unwrap' {
993
  if ($args.Count -ge 2 -and $args[1] -eq 'openclaw') {
994
+ $unwrapArgs = if ($args.Count -gt 2) { $args[2..($args.Count - 1)] } else { @() }
995
+ if (Test-HelpFlag -Arguments $unwrapArgs) {
996
+ $helpArgs = @('unwrap','openclaw') + $unwrapArgs
997
+ Invoke-HeadroomDocker -Arguments $helpArgs
998
+ exit 0
999
+ }
1000
+
1001
+ Invoke-OpenClawUnwrap -Arguments $unwrapArgs
1002
+ exit 0
1003
  }
1004
  Invoke-HeadroomDocker -Arguments $args
1005
  }
scripts/install.sh CHANGED
@@ -338,6 +338,451 @@ run_host_tool() {
338
  "${binary}" "$@"
339
  }
340
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
341
  main() {
342
  require_cmd docker
343
 
@@ -348,10 +793,19 @@ main() {
348
 
349
  case "$1" in
350
  wrap)
351
- (($# >= 2)) || die "Usage: headroom wrap <claude|codex|aider|cursor> [...]"
352
  local tool="$2"
353
  shift 2
354
 
 
 
 
 
 
 
 
 
 
355
  local known_args host_args port no_rtk no_proxy learn backend anyllm region
356
  parse_wrap_args known_args host_args port no_rtk no_proxy learn backend anyllm region "$@"
357
 
@@ -372,9 +826,6 @@ main() {
372
  case "${tool}" in
373
  claude|codex|aider|cursor)
374
  ;;
375
- openclaw)
376
- die "Docker-native install does not support 'headroom wrap openclaw' yet. Use a native Headroom install for OpenClaw plugin management."
377
- ;;
378
  *)
379
  die "Unsupported wrap target: ${tool}"
380
  ;;
@@ -424,7 +875,13 @@ EOF
424
  ;;
425
  unwrap)
426
  if (($# >= 2)) && [[ "$2" == "openclaw" ]]; then
427
- die "Docker-native install does not support 'headroom unwrap openclaw' yet. Use a native Headroom install for OpenClaw plugin management."
 
 
 
 
 
 
428
  fi
429
  run_headroom "$@"
430
  ;;
 
338
  "${binary}" "$@"
339
  }
340
 
341
+ contains_help_flag() {
342
+ local arg
343
+ for arg in "$@"; do
344
+ if [[ "${arg}" == "--help" || "${arg}" == "-?" ]]; then
345
+ return 0
346
+ fi
347
+ done
348
+
349
+ return 1
350
+ }
351
+
352
+ parse_openclaw_wrap_args() {
353
+ local -n out_plugin_path=$1
354
+ local -n out_plugin_spec=$2
355
+ local -n out_skip_build=$3
356
+ local -n out_copy=$4
357
+ local -n out_proxy_port=$5
358
+ local -n out_startup_timeout_ms=$6
359
+ local -n out_gateway_provider_ids=$7
360
+ local -n out_python_path=$8
361
+ local -n out_no_auto_start=$9
362
+ local -n out_no_restart=${10}
363
+ local -n out_verbose=${11}
364
+ shift 11
365
+
366
+ out_plugin_path=""
367
+ out_plugin_spec="headroom-ai/openclaw"
368
+ out_skip_build=0
369
+ out_copy=0
370
+ out_proxy_port=8787
371
+ out_startup_timeout_ms=20000
372
+ out_gateway_provider_ids=()
373
+ out_python_path=""
374
+ out_no_auto_start=0
375
+ out_no_restart=0
376
+ out_verbose=0
377
+
378
+ while (($#)); do
379
+ case "$1" in
380
+ --plugin-path)
381
+ out_plugin_path="$2"
382
+ shift 2
383
+ ;;
384
+ --plugin-path=*)
385
+ out_plugin_path="${1#*=}"
386
+ shift
387
+ ;;
388
+ --plugin-spec)
389
+ out_plugin_spec="$2"
390
+ shift 2
391
+ ;;
392
+ --plugin-spec=*)
393
+ out_plugin_spec="${1#*=}"
394
+ shift
395
+ ;;
396
+ --skip-build)
397
+ out_skip_build=1
398
+ shift
399
+ ;;
400
+ --copy)
401
+ out_copy=1
402
+ shift
403
+ ;;
404
+ --proxy-port)
405
+ out_proxy_port="$2"
406
+ shift 2
407
+ ;;
408
+ --proxy-port=*)
409
+ out_proxy_port="${1#*=}"
410
+ shift
411
+ ;;
412
+ --startup-timeout-ms)
413
+ out_startup_timeout_ms="$2"
414
+ shift 2
415
+ ;;
416
+ --startup-timeout-ms=*)
417
+ out_startup_timeout_ms="${1#*=}"
418
+ shift
419
+ ;;
420
+ --gateway-provider-id)
421
+ out_gateway_provider_ids+=("$2")
422
+ shift 2
423
+ ;;
424
+ --gateway-provider-id=*)
425
+ out_gateway_provider_ids+=("${1#*=}")
426
+ shift
427
+ ;;
428
+ --python-path)
429
+ out_python_path="$2"
430
+ shift 2
431
+ ;;
432
+ --python-path=*)
433
+ out_python_path="${1#*=}"
434
+ shift
435
+ ;;
436
+ --no-auto-start)
437
+ out_no_auto_start=1
438
+ shift
439
+ ;;
440
+ --no-restart)
441
+ out_no_restart=1
442
+ shift
443
+ ;;
444
+ --verbose|-v)
445
+ out_verbose=1
446
+ shift
447
+ ;;
448
+ *)
449
+ die "Unsupported option for 'headroom wrap openclaw': $1"
450
+ ;;
451
+ esac
452
+ done
453
+ }
454
+
455
+ parse_openclaw_unwrap_args() {
456
+ local -n out_no_restart=$1
457
+ local -n out_verbose=$2
458
+ shift 2
459
+
460
+ out_no_restart=0
461
+ out_verbose=0
462
+
463
+ while (($#)); do
464
+ case "$1" in
465
+ --no-restart)
466
+ out_no_restart=1
467
+ shift
468
+ ;;
469
+ --verbose|-v)
470
+ out_verbose=1
471
+ shift
472
+ ;;
473
+ *)
474
+ die "Unsupported option for 'headroom unwrap openclaw': $1"
475
+ ;;
476
+ esac
477
+ done
478
+ }
479
+
480
+ get_openclaw_existing_entry_json() {
481
+ local output=""
482
+ if output="$(openclaw config get plugins.entries.headroom 2>/dev/null)"; then
483
+ printf '%s' "${output}"
484
+ fi
485
+ }
486
+
487
+ prepare_openclaw_entry_json() {
488
+ local existing_entry_json="$1"
489
+ local proxy_port="$2"
490
+ local startup_timeout_ms="$3"
491
+ local python_path="$4"
492
+ local no_auto_start="$5"
493
+ shift 5
494
+ local gateway_provider_ids=("$@")
495
+ local args=()
496
+ args=(docker run --rm)
497
+ append_common_container_args args
498
+ args+=(--entrypoint headroom "${HEADROOM_IMAGE}" wrap openclaw --prepare-only)
499
+ args+=(--proxy-port "${proxy_port}" --startup-timeout-ms "${startup_timeout_ms}")
500
+
501
+ if [[ -n "${existing_entry_json}" ]]; then
502
+ args+=(--existing-entry-json "${existing_entry_json}")
503
+ fi
504
+ if [[ -n "${python_path}" ]]; then
505
+ args+=(--python-path "${python_path}")
506
+ fi
507
+ if [[ "${no_auto_start}" -eq 1 ]]; then
508
+ args+=(--no-auto-start)
509
+ fi
510
+
511
+ local provider_id
512
+ for provider_id in "${gateway_provider_ids[@]}"; do
513
+ args+=(--gateway-provider-id "${provider_id}")
514
+ done
515
+
516
+ "${args[@]}"
517
+ }
518
+
519
+ prepare_openclaw_unwrap_entry_json() {
520
+ local existing_entry_json="$1"
521
+ local args=()
522
+ args=(docker run --rm)
523
+ append_common_container_args args
524
+ args+=(--entrypoint headroom "${HEADROOM_IMAGE}" unwrap openclaw --prepare-only)
525
+ if [[ -n "${existing_entry_json}" ]]; then
526
+ args+=(--existing-entry-json "${existing_entry_json}")
527
+ fi
528
+ "${args[@]}"
529
+ }
530
+
531
+ run_openclaw_checked() {
532
+ local action="$1"
533
+ shift
534
+ local output=""
535
+
536
+ if ! output="$("$@" 2>&1)"; then
537
+ output="${output//$'\r'/}"
538
+ die "${action} failed: ${output:-unknown error}"
539
+ fi
540
+
541
+ printf '%s' "${output//$'\r'/}"
542
+ }
543
+
544
+ run_openclaw_checked_in_dir() {
545
+ local action="$1"
546
+ local cwd="$2"
547
+ shift 2
548
+ local output=""
549
+
550
+ if ! output="$(cd "${cwd}" && "$@" 2>&1)"; then
551
+ output="${output//$'\r'/}"
552
+ die "${action} failed: ${output:-unknown error}"
553
+ fi
554
+
555
+ printf '%s' "${output//$'\r'/}"
556
+ }
557
+
558
+ resolve_openclaw_extensions_dir() {
559
+ local config_output
560
+ config_output="$(run_openclaw_checked "openclaw config file" openclaw config file)"
561
+ local config_path
562
+ config_path="$(printf '%s\n' "${config_output}" | tail -n 1)"
563
+ [[ -n "${config_path}" ]] || die "Unable to resolve OpenClaw config path."
564
+ printf '%s\n' "$(dirname "${config_path}")/extensions"
565
+ }
566
+
567
+ copy_openclaw_plugin_into_extensions() {
568
+ local plugin_dir="$1"
569
+ local dist_dir="${plugin_dir}/dist"
570
+ local hook_shim_dir="${plugin_dir}/hook-shim"
571
+ [[ -d "${dist_dir}" ]] || die "Plugin dist folder missing at ${dist_dir}. Build the plugin first."
572
+ [[ -d "${hook_shim_dir}" ]] || die "Plugin hook-shim folder missing at ${hook_shim_dir}. Build the plugin first."
573
+
574
+ local extensions_dir
575
+ extensions_dir="$(resolve_openclaw_extensions_dir)"
576
+ local target_dir="${extensions_dir}/headroom"
577
+ mkdir -p "${target_dir}"
578
+ rm -rf "${target_dir}/dist" "${target_dir}/hook-shim"
579
+ cp -R "${dist_dir}" "${target_dir}/dist"
580
+ cp -R "${hook_shim_dir}" "${target_dir}/hook-shim"
581
+
582
+ local filename
583
+ for filename in openclaw.plugin.json package.json README.md; do
584
+ if [[ -f "${plugin_dir}/${filename}" ]]; then
585
+ cp "${plugin_dir}/${filename}" "${target_dir}/${filename}"
586
+ fi
587
+ done
588
+
589
+ printf '%s\n' "${target_dir}"
590
+ }
591
+
592
+ install_openclaw_plugin() {
593
+ local plugin_path="$1"
594
+ local plugin_spec="$2"
595
+ local skip_build="$3"
596
+ local copy_mode="$4"
597
+ local verbose="$5"
598
+
599
+ local local_source_mode=0
600
+ if [[ -n "${plugin_path}" ]]; then
601
+ local_source_mode=1
602
+ [[ -d "${plugin_path}" ]] || die "Plugin path not found: ${plugin_path}."
603
+ [[ -f "${plugin_path}/package.json" ]] || die "Invalid plugin path (missing package.json): ${plugin_path}"
604
+ [[ -f "${plugin_path}/openclaw.plugin.json" ]] || die "Invalid plugin path (missing openclaw.plugin.json): ${plugin_path}"
605
+ fi
606
+
607
+ if [[ "${local_source_mode}" -eq 1 && "${skip_build}" -eq 0 ]]; then
608
+ require_cmd npm
609
+ info "Building OpenClaw plugin (npm install + npm run build)..."
610
+ run_openclaw_checked_in_dir "npm install" "${plugin_path}" npm install >/dev/null
611
+ run_openclaw_checked_in_dir "npm run build" "${plugin_path}" npm run build >/dev/null
612
+ fi
613
+
614
+ local install_output=""
615
+ local install_status=0
616
+ set +e
617
+ if [[ "${local_source_mode}" -eq 1 ]]; then
618
+ if [[ "${copy_mode}" -eq 1 ]]; then
619
+ install_output="$(openclaw plugins install --dangerously-force-unsafe-install "${plugin_path}" 2>&1)"
620
+ install_status=$?
621
+ else
622
+ install_output="$(cd "${plugin_path}" && openclaw plugins install --dangerously-force-unsafe-install --link . 2>&1)"
623
+ install_status=$?
624
+ fi
625
+ else
626
+ install_output="$(openclaw plugins install --dangerously-force-unsafe-install "${plugin_spec}" 2>&1)"
627
+ install_status=$?
628
+ fi
629
+ set -e
630
+ install_output="${install_output//$'\r'/}"
631
+
632
+ if [[ "${install_status}" -eq 0 ]]; then
633
+ if [[ "${verbose}" -eq 1 && -n "${install_output}" ]]; then
634
+ printf '%s\n' "${install_output}"
635
+ fi
636
+ return
637
+ fi
638
+
639
+ local lower_output="${install_output,,}"
640
+ if [[ "${lower_output}" == *"plugin already exists"* ]]; then
641
+ info "Plugin already installed; continuing with configuration/update steps."
642
+ return
643
+ fi
644
+
645
+ if [[ "${lower_output}" == *"also not a valid hook pack"* && "${local_source_mode}" -eq 1 && "${copy_mode}" -eq 0 ]]; then
646
+ info "OpenClaw linked-path install bug detected; applying extension-path fallback..."
647
+ local target_dir
648
+ target_dir="$(copy_openclaw_plugin_into_extensions "${plugin_path}")"
649
+ info "Fallback plugin copy completed: ${target_dir}"
650
+ return
651
+ fi
652
+
653
+ die "openclaw plugins install failed: ${install_output:-exit code ${install_status}}"
654
+ }
655
+
656
+ restart_or_start_openclaw_gateway() {
657
+ local output=""
658
+ if output="$(openclaw gateway restart 2>&1)"; then
659
+ OPENCLAW_GATEWAY_ACTION="restarted"
660
+ OPENCLAW_GATEWAY_OUTPUT="${output//$'\r'/}"
661
+ return
662
+ fi
663
+
664
+ OPENCLAW_GATEWAY_OUTPUT="$(run_openclaw_checked "openclaw gateway start" openclaw gateway start)"
665
+ OPENCLAW_GATEWAY_ACTION="started"
666
+ }
667
+
668
+ wrap_openclaw_host() {
669
+ local plugin_path plugin_spec skip_build copy_mode proxy_port startup_timeout_ms python_path
670
+ local no_auto_start no_restart verbose
671
+ local gateway_provider_ids=()
672
+
673
+ parse_openclaw_wrap_args \
674
+ plugin_path \
675
+ plugin_spec \
676
+ skip_build \
677
+ copy_mode \
678
+ proxy_port \
679
+ startup_timeout_ms \
680
+ gateway_provider_ids \
681
+ python_path \
682
+ no_auto_start \
683
+ no_restart \
684
+ verbose \
685
+ "$@"
686
+
687
+ require_cmd openclaw
688
+ local existing_entry_json=""
689
+ existing_entry_json="$(get_openclaw_existing_entry_json)"
690
+ local entry_json
691
+ entry_json="$(prepare_openclaw_entry_json "${existing_entry_json}" "${proxy_port}" "${startup_timeout_ms}" "${python_path}" "${no_auto_start}" "${gateway_provider_ids[@]}")"
692
+
693
+ printf '\n ╔═══════════════════════════════════════════════╗\n'
694
+ printf ' ║ HEADROOM WRAP: OPENCLAW ║\n'
695
+ printf ' ╚═══════════════════════════════════════════════╝\n\n'
696
+ if [[ -n "${plugin_path}" ]]; then
697
+ printf ' Plugin source: local (%s)\n' "${plugin_path}"
698
+ else
699
+ printf ' Plugin source: npm (%s)\n' "${plugin_spec}"
700
+ fi
701
+
702
+ printf ' Writing plugin configuration...\n'
703
+ run_openclaw_checked \
704
+ "openclaw config set plugins.entries.headroom" \
705
+ openclaw config set plugins.entries.headroom "${entry_json}" --strict-json >/dev/null
706
+
707
+ printf ' Installing OpenClaw plugin with required unsafe-install flag...\n'
708
+ install_openclaw_plugin "${plugin_path}" "${plugin_spec}" "${skip_build}" "${copy_mode}" "${verbose}"
709
+
710
+ run_openclaw_checked \
711
+ "openclaw config set plugins.slots.contextEngine" \
712
+ openclaw config set plugins.slots.contextEngine '"headroom"' --strict-json >/dev/null
713
+ run_openclaw_checked "openclaw config validate" openclaw config validate >/dev/null
714
+
715
+ if [[ "${no_restart}" -eq 1 ]]; then
716
+ printf ' Skipping gateway restart (--no-restart).\n'
717
+ printf ' Run `openclaw gateway restart` (or `openclaw gateway start`) to apply plugin changes.\n'
718
+ else
719
+ printf ' Applying plugin changes to OpenClaw gateway...\n'
720
+ restart_or_start_openclaw_gateway
721
+ printf ' Gateway %s.\n' "${OPENCLAW_GATEWAY_ACTION}"
722
+ if [[ "${verbose}" -eq 1 && -n "${OPENCLAW_GATEWAY_OUTPUT}" ]]; then
723
+ printf '%s\n' "${OPENCLAW_GATEWAY_OUTPUT}"
724
+ fi
725
+ fi
726
+
727
+ local inspect_output=""
728
+ inspect_output="$(run_openclaw_checked "openclaw plugins inspect headroom" openclaw plugins inspect headroom)"
729
+ if [[ "${verbose}" -eq 1 && -n "${inspect_output}" ]]; then
730
+ printf '%s\n' "${inspect_output}"
731
+ fi
732
+
733
+ printf '\n✓ OpenClaw is configured to use Headroom context compression.\n'
734
+ printf ' Plugin: headroom\n'
735
+ printf ' Slot: plugins.slots.contextEngine = headroom\n\n'
736
+ }
737
+
738
+ unwrap_openclaw_host() {
739
+ local no_restart verbose
740
+ parse_openclaw_unwrap_args no_restart verbose "$@"
741
+
742
+ require_cmd openclaw
743
+ local existing_entry_json=""
744
+ existing_entry_json="$(get_openclaw_existing_entry_json)"
745
+ local entry_json
746
+ entry_json="$(prepare_openclaw_unwrap_entry_json "${existing_entry_json}")"
747
+
748
+ printf '\n ╔═══════════════════════════════════════════════╗\n'
749
+ printf ' ║ HEADROOM UNWRAP: OPENCLAW ║\n'
750
+ printf ' ╚═══════════════════════════════════════════════╝\n\n'
751
+ printf ' Disabling Headroom plugin and removing engine mapping...\n'
752
+
753
+ run_openclaw_checked \
754
+ "openclaw config set plugins.entries.headroom" \
755
+ openclaw config set plugins.entries.headroom "${entry_json}" --strict-json >/dev/null
756
+ run_openclaw_checked \
757
+ "openclaw config set plugins.slots.contextEngine" \
758
+ openclaw config set plugins.slots.contextEngine '"legacy"' --strict-json >/dev/null
759
+ run_openclaw_checked "openclaw config validate" openclaw config validate >/dev/null
760
+
761
+ if [[ "${no_restart}" -eq 1 ]]; then
762
+ printf ' Skipping gateway restart (--no-restart).\n'
763
+ printf ' Run `openclaw gateway restart` (or `openclaw gateway start`) to apply unwrap changes.\n'
764
+ else
765
+ printf ' Applying unwrap changes to OpenClaw gateway...\n'
766
+ restart_or_start_openclaw_gateway
767
+ printf ' Gateway %s.\n' "${OPENCLAW_GATEWAY_ACTION}"
768
+ if [[ "${verbose}" -eq 1 && -n "${OPENCLAW_GATEWAY_OUTPUT}" ]]; then
769
+ printf '%s\n' "${OPENCLAW_GATEWAY_OUTPUT}"
770
+ fi
771
+ fi
772
+
773
+ if [[ "${verbose}" -eq 1 ]]; then
774
+ local inspect_output=""
775
+ inspect_output="$(run_openclaw_checked "openclaw plugins inspect headroom" openclaw plugins inspect headroom)"
776
+ if [[ -n "${inspect_output}" ]]; then
777
+ printf '%s\n' "${inspect_output}"
778
+ fi
779
+ fi
780
+
781
+ printf '\n✓ OpenClaw Headroom wrap removed.\n'
782
+ printf ' Plugin: headroom (installed, disabled)\n'
783
+ printf ' Slot: plugins.slots.contextEngine = legacy\n\n'
784
+ }
785
+
786
  main() {
787
  require_cmd docker
788
 
 
793
 
794
  case "$1" in
795
  wrap)
796
+ (($# >= 2)) || die "Usage: headroom wrap <claude|codex|aider|cursor|openclaw> [...]"
797
  local tool="$2"
798
  shift 2
799
 
800
+ if [[ "${tool}" == "openclaw" ]]; then
801
+ if contains_help_flag "$@"; then
802
+ run_headroom wrap openclaw "$@"
803
+ return
804
+ fi
805
+ wrap_openclaw_host "$@"
806
+ return
807
+ fi
808
+
809
  local known_args host_args port no_rtk no_proxy learn backend anyllm region
810
  parse_wrap_args known_args host_args port no_rtk no_proxy learn backend anyllm region "$@"
811
 
 
826
  case "${tool}" in
827
  claude|codex|aider|cursor)
828
  ;;
 
 
 
829
  *)
830
  die "Unsupported wrap target: ${tool}"
831
  ;;
 
875
  ;;
876
  unwrap)
877
  if (($# >= 2)) && [[ "$2" == "openclaw" ]]; then
878
+ shift 2
879
+ if contains_help_flag "$@"; then
880
+ run_headroom unwrap openclaw "$@"
881
+ return
882
+ fi
883
+ unwrap_openclaw_host "$@"
884
+ return
885
  fi
886
  run_headroom "$@"
887
  ;;
tests/test_cli/test_wrap_bridge.py CHANGED
@@ -2,6 +2,7 @@
2
 
3
  from __future__ import annotations
4
 
 
5
  from pathlib import Path
6
  from unittest.mock import patch
7
 
@@ -68,3 +69,56 @@ def test_wrap_cursor_prepare_only_injects_cursorrules(monkeypatch, tmp_path: Pat
68
  cursorrules = Path(".cursorrules")
69
  assert cursorrules.exists()
70
  assert "headroom:rtk-instructions" in cursorrules.read_text()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  from __future__ import annotations
4
 
5
+ import json
6
  from pathlib import Path
7
  from unittest.mock import patch
8
 
 
69
  cursorrules = Path(".cursorrules")
70
  assert cursorrules.exists()
71
  assert "headroom:rtk-instructions" in cursorrules.read_text()
72
+
73
+
74
+ def test_wrap_openclaw_prepare_only_emits_config_without_python_default() -> None:
75
+ runner = CliRunner()
76
+
77
+ result = runner.invoke(
78
+ main,
79
+ [
80
+ "wrap",
81
+ "openclaw",
82
+ "--prepare-only",
83
+ "--gateway-provider-id",
84
+ "codex",
85
+ "--gateway-provider-id",
86
+ "anthropic",
87
+ ],
88
+ )
89
+
90
+ assert result.exit_code == 0, result.output
91
+ payload = json.loads(result.output)
92
+ assert payload["enabled"] is True
93
+ assert payload["config"]["proxyPort"] == 8787
94
+ assert payload["config"]["gatewayProviderIds"] == ["codex", "anthropic"]
95
+ assert "pythonPath" not in payload["config"]
96
+
97
+
98
+ def test_unwrap_openclaw_prepare_only_preserves_unmanaged_config() -> None:
99
+ runner = CliRunner()
100
+ existing_entry = json.dumps(
101
+ {
102
+ "enabled": True,
103
+ "config": {
104
+ "pythonPath": "C:\\Python312\\python.exe",
105
+ "proxyPort": 8787,
106
+ "customFlag": True,
107
+ },
108
+ }
109
+ )
110
+
111
+ result = runner.invoke(
112
+ main,
113
+ [
114
+ "unwrap",
115
+ "openclaw",
116
+ "--prepare-only",
117
+ "--existing-entry-json",
118
+ existing_entry,
119
+ ],
120
+ )
121
+
122
+ assert result.exit_code == 0, result.output
123
+ payload = json.loads(result.output)
124
+ assert payload == {"enabled": False, "config": {"customFlag": True}}
tests/test_cli/test_wrap_openclaw.py CHANGED
@@ -389,6 +389,24 @@ def test_build_openclaw_plugin_entry_sets_and_clears_python_path() -> None:
389
  assert without_python["config"]["customFlag"] is True
390
 
391
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
392
  def test_wrap_openclaw_no_auto_start_does_not_default_python_path(
393
  runner: CliRunner, plugin_dir: Path
394
  ) -> None:
 
389
  assert without_python["config"]["customFlag"] is True
390
 
391
 
392
+ def test_build_openclaw_unwrap_entry_preserves_top_level_metadata() -> None:
393
+ entry = wrap_cli._build_openclaw_unwrap_entry(
394
+ {
395
+ "source": "headroom-ai/openclaw",
396
+ "enabled": True,
397
+ "config": {
398
+ "pythonPath": "C:\\Python312\\python.exe",
399
+ "proxyPort": 8787,
400
+ "customFlag": True,
401
+ },
402
+ }
403
+ )
404
+
405
+ assert entry["source"] == "headroom-ai/openclaw"
406
+ assert entry["enabled"] is False
407
+ assert entry["config"] == {"customFlag": True}
408
+
409
+
410
  def test_wrap_openclaw_no_auto_start_does_not_default_python_path(
411
  runner: CliRunner, plugin_dir: Path
412
  ) -> None: