JerrettDavis commited on
Commit
c6897fd
·
1 Parent(s): 0123b2a

feat(cli): add one-command OpenClaw wrap bootstrap

Browse files
README.md CHANGED
@@ -125,6 +125,7 @@ headroom wrap claude # Starts proxy + launches Claude Code
125
  headroom wrap codex # Starts proxy + launches OpenAI Codex CLI
126
  headroom wrap aider # Starts proxy + launches Aider
127
  headroom wrap cursor # Starts proxy + prints Cursor config
 
128
  ```
129
 
130
  Headroom starts a proxy, points your tool at it, and compresses everything automatically.
@@ -164,7 +165,7 @@ Gives your AI tool three MCP tools: `headroom_compress`, `headroom_retrieve`, `h
164
  | **Any Python proxy** | ASGI Middleware | `app.add_middleware(CompressionMiddleware)` |
165
  | **Agno agents** | Wrap model | `HeadroomAgnoModel(your_model)` |
166
  | **LangChain** | Wrap model | `HeadroomChatModel(your_llm)` |
167
- | **OpenClaw** | ContextEngine plugin | [See OpenClaw plugin](#openclaw-plugin) |
168
  | **Claude Code** | Wrap | `headroom wrap claude` |
169
  | **Codex / Aider** | Wrap | `headroom wrap codex` or `headroom wrap aider` |
170
 
 
125
  headroom wrap codex # Starts proxy + launches OpenAI Codex CLI
126
  headroom wrap aider # Starts proxy + launches Aider
127
  headroom wrap cursor # Starts proxy + prints Cursor config
128
+ headroom wrap openclaw # Installs + configures OpenClaw plugin
129
  ```
130
 
131
  Headroom starts a proxy, points your tool at it, and compresses everything automatically.
 
165
  | **Any Python proxy** | ASGI Middleware | `app.add_middleware(CompressionMiddleware)` |
166
  | **Agno agents** | Wrap model | `HeadroomAgnoModel(your_model)` |
167
  | **LangChain** | Wrap model | `HeadroomChatModel(your_llm)` |
168
+ | **OpenClaw** | One-command wrap | `headroom wrap openclaw` |
169
  | **Claude Code** | Wrap | `headroom wrap claude` |
170
  | **Codex / Aider** | Wrap | `headroom wrap codex` or `headroom wrap aider` |
171
 
docs/index.md CHANGED
@@ -98,6 +98,7 @@ Headroom works as a **transparent proxy** (zero code changes), a **Python functi
98
  headroom wrap codex # OpenAI Codex CLI
99
  headroom wrap aider # Aider
100
  headroom wrap cursor # Cursor
 
101
  ```
102
 
103
  Starts the proxy, points your tool at it, compresses everything automatically.
@@ -215,7 +216,7 @@ npm install headroom-ai
215
  ContextEngine plugin for OpenClaw agents. Auto-compresses context in `assemble()`.
216
 
217
  ```bash
218
- openclaw plugins install headroom-openclaw
219
  ```
220
 
221
  [OpenClaw Plugin →](https://github.com/chopratejas/headroom/tree/main/plugins/openclaw)
 
98
  headroom wrap codex # OpenAI Codex CLI
99
  headroom wrap aider # Aider
100
  headroom wrap cursor # Cursor
101
+ headroom wrap openclaw # OpenClaw plugin bootstrap
102
  ```
103
 
104
  Starts the proxy, points your tool at it, compresses everything automatically.
 
216
  ContextEngine plugin for OpenClaw agents. Auto-compresses context in `assemble()`.
217
 
218
  ```bash
219
+ headroom wrap openclaw
220
  ```
221
 
222
  [OpenClaw Plugin →](https://github.com/chopratejas/headroom/tree/main/plugins/openclaw)
headroom/cli/wrap.py CHANGED
@@ -5,6 +5,7 @@ Usage:
5
  headroom wrap codex # Start proxy + OpenAI Codex CLI
6
  headroom wrap aider # Start proxy + aider
7
  headroom wrap cursor # Start proxy + print Cursor config instructions
 
8
  headroom wrap claude --no-rtk # Without rtk hooks
9
  headroom wrap claude --port 9999 # Custom proxy port
10
  headroom wrap claude -- --model opus # Pass args to claude
@@ -13,6 +14,7 @@ Usage:
13
  from __future__ import annotations
14
 
15
  import io
 
16
  import os
17
  import shutil
18
  import signal
@@ -384,6 +386,75 @@ def _launch_tool(
384
  cleanup()
385
 
386
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
387
  @main.group()
388
  def wrap() -> None:
389
  """Wrap CLI tools to run through Headroom.
@@ -398,6 +469,7 @@ def wrap() -> None:
398
  headroom wrap codex # OpenAI Codex CLI
399
  headroom wrap aider # Aider
400
  headroom wrap cursor # Cursor (prints config instructions)
 
401
  """
402
 
403
 
@@ -744,3 +816,213 @@ def cursor(port: int, no_rtk: bool, no_proxy: bool, learn: bool, verbose: bool)
744
  raise SystemExit(1) from e
745
  finally:
746
  cleanup()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  headroom wrap codex # Start proxy + OpenAI Codex CLI
6
  headroom wrap aider # Start proxy + aider
7
  headroom wrap cursor # Start proxy + print Cursor config instructions
8
+ headroom wrap openclaw # Install + configure OpenClaw plugin
9
  headroom wrap claude --no-rtk # Without rtk hooks
10
  headroom wrap claude --port 9999 # Custom proxy port
11
  headroom wrap claude -- --model opus # Pass args to claude
 
14
  from __future__ import annotations
15
 
16
  import io
17
+ import json
18
  import os
19
  import shutil
20
  import signal
 
386
  cleanup()
387
 
388
 
389
+ def _run_checked(
390
+ cmd: list[str],
391
+ *,
392
+ cwd: Path | None = None,
393
+ action: str,
394
+ ) -> subprocess.CompletedProcess[str]:
395
+ """Run subprocess and raise a ClickException with actionable context on failure."""
396
+ try:
397
+ return subprocess.run(
398
+ cmd,
399
+ cwd=str(cwd) if cwd else None,
400
+ check=True,
401
+ capture_output=True,
402
+ text=True,
403
+ encoding="utf-8",
404
+ errors="replace",
405
+ )
406
+ except FileNotFoundError as e:
407
+ raise click.ClickException(f"{action} failed: command not found: {cmd[0]}") from e
408
+ except subprocess.CalledProcessError as e:
409
+ stderr = (e.stderr or "").strip()
410
+ stdout = (e.stdout or "").strip()
411
+ details = stderr or stdout or f"exit code {e.returncode}"
412
+ raise click.ClickException(f"{action} failed: {details}") from e
413
+
414
+
415
+ def _default_openclaw_plugin_dir() -> Path:
416
+ """Return repo-relative plugins/openclaw path for editable/dev installs."""
417
+ return Path(__file__).resolve().parents[2] / "plugins" / "openclaw"
418
+
419
+
420
+ def _resolve_openclaw_extensions_dir(openclaw_bin: str) -> Path:
421
+ """Resolve OpenClaw extension root from active config file path."""
422
+ result = _run_checked([openclaw_bin, "config", "file"], action="openclaw config file")
423
+ config_path_str = result.stdout.strip().splitlines()[-1].strip()
424
+ if not config_path_str:
425
+ raise click.ClickException("Unable to resolve OpenClaw config path from `openclaw config file`.")
426
+ config_path = Path(config_path_str).expanduser()
427
+ return config_path.parent / "extensions"
428
+
429
+
430
+ def _copy_openclaw_plugin_into_extensions(
431
+ *,
432
+ plugin_dir: Path,
433
+ openclaw_bin: str,
434
+ ) -> Path:
435
+ """Fallback install path when `openclaw plugins install` is blocked on linked source."""
436
+ dist_dir = plugin_dir / "dist"
437
+ if not dist_dir.exists():
438
+ raise click.ClickException(
439
+ f"Plugin dist folder missing at {dist_dir}. Build the plugin first."
440
+ )
441
+
442
+ extensions_dir = _resolve_openclaw_extensions_dir(openclaw_bin)
443
+ target_dir = extensions_dir / "headroom"
444
+ target_dist = target_dir / "dist"
445
+ target_dir.mkdir(parents=True, exist_ok=True)
446
+ if target_dist.exists():
447
+ shutil.rmtree(target_dist)
448
+ shutil.copytree(dist_dir, target_dist)
449
+
450
+ for filename in ("openclaw.plugin.json", "package.json", "README.md"):
451
+ source = plugin_dir / filename
452
+ if source.exists():
453
+ shutil.copy2(source, target_dir / filename)
454
+
455
+ return target_dir
456
+
457
+
458
  @main.group()
459
  def wrap() -> None:
460
  """Wrap CLI tools to run through Headroom.
 
469
  headroom wrap codex # OpenAI Codex CLI
470
  headroom wrap aider # Aider
471
  headroom wrap cursor # Cursor (prints config instructions)
472
+ headroom wrap openclaw # OpenClaw plugin bootstrap
473
  """
474
 
475
 
 
816
  raise SystemExit(1) from e
817
  finally:
818
  cleanup()
819
+
820
+
821
+ # =============================================================================
822
+ # OpenClaw
823
+ # =============================================================================
824
+
825
+
826
+ @wrap.command("openclaw")
827
+ @click.option(
828
+ "--plugin-path",
829
+ type=click.Path(path_type=Path, file_okay=False, dir_okay=True),
830
+ default=None,
831
+ help="Path to the OpenClaw plugin source directory (default: repo plugins/openclaw)",
832
+ )
833
+ @click.option(
834
+ "--skip-build",
835
+ is_flag=True,
836
+ help="Skip npm install/build before plugin install",
837
+ )
838
+ @click.option(
839
+ "--copy",
840
+ is_flag=True,
841
+ help="Install by copying plugin path instead of using --link",
842
+ )
843
+ @click.option("--proxy-port", default=8787, type=int, help="Headroom proxy port")
844
+ @click.option("--startup-timeout-ms", default=20000, type=int, help="Proxy startup timeout")
845
+ @click.option(
846
+ "--python-path",
847
+ default=None,
848
+ help="Optional Python executable for proxy launcher fallback",
849
+ )
850
+ @click.option(
851
+ "--no-auto-start",
852
+ is_flag=True,
853
+ help="Disable plugin auto-start of local headroom proxy",
854
+ )
855
+ @click.option(
856
+ "--no-restart",
857
+ is_flag=True,
858
+ help="Do not restart OpenClaw gateway at the end",
859
+ )
860
+ @click.option("--verbose", "-v", is_flag=True, help="Verbose output")
861
+ def openclaw(
862
+ plugin_path: Path | None,
863
+ skip_build: bool,
864
+ copy: bool,
865
+ proxy_port: int,
866
+ startup_timeout_ms: int,
867
+ python_path: str | None,
868
+ no_auto_start: bool,
869
+ no_restart: bool,
870
+ verbose: bool,
871
+ ) -> None:
872
+ """Install and configure Headroom OpenClaw plugin in one command.
873
+
874
+ \b
875
+ What this command does:
876
+ 1. Builds plugin source (npm install + npm run build)
877
+ 2. Installs plugin with unsafe-install flag required by OpenClaw
878
+ 3. Writes minimal plugin config and sets contextEngine slot
879
+ 4. Validates config
880
+ 5. Restarts OpenClaw gateway (unless --no-restart)
881
+
882
+ \b
883
+ Example:
884
+ headroom wrap openclaw
885
+ headroom wrap openclaw --plugin-path C:\\git\\headroom\\plugins\\openclaw
886
+ """
887
+ openclaw_bin = shutil.which("openclaw")
888
+ if not openclaw_bin:
889
+ raise click.ClickException(
890
+ "'openclaw' not found in PATH. Install OpenClaw CLI first."
891
+ )
892
+
893
+ plugin_dir = (plugin_path or _default_openclaw_plugin_dir()).resolve()
894
+ if not plugin_dir.exists():
895
+ raise click.ClickException(
896
+ f"Plugin path not found: {plugin_dir}. Pass --plugin-path explicitly."
897
+ )
898
+ if not (plugin_dir / "package.json").exists():
899
+ raise click.ClickException(f"Invalid plugin path (missing package.json): {plugin_dir}")
900
+ if not (plugin_dir / "openclaw.plugin.json").exists():
901
+ raise click.ClickException(
902
+ f"Invalid plugin path (missing openclaw.plugin.json): {plugin_dir}"
903
+ )
904
+
905
+ npm_bin = shutil.which("npm")
906
+ if not skip_build and not npm_bin:
907
+ raise click.ClickException(
908
+ "'npm' not found in PATH. Install Node/npm or rerun with --skip-build."
909
+ )
910
+
911
+ click.echo()
912
+ click.echo(" ╔═══════════════════════════════════════════════╗")
913
+ click.echo(" ║ HEADROOM WRAP: OPENCLAW ║")
914
+ click.echo(" ╚═══════════════════════════════════════════════╝")
915
+ click.echo()
916
+ click.echo(f" Plugin source: {plugin_dir}")
917
+
918
+ if not skip_build:
919
+ click.echo(" Building OpenClaw plugin (npm install + npm run build)...")
920
+ _run_checked([npm_bin or "npm", "install"], cwd=plugin_dir, action="npm install")
921
+ _run_checked([npm_bin or "npm", "run", "build"], cwd=plugin_dir, action="npm run build")
922
+
923
+ install_cmd = [
924
+ openclaw_bin,
925
+ "plugins",
926
+ "install",
927
+ "--dangerously-force-unsafe-install",
928
+ ]
929
+ if copy:
930
+ install_cmd.append(str(plugin_dir))
931
+ install_cwd = None
932
+ else:
933
+ install_cmd.extend(["--link", "."])
934
+ install_cwd = plugin_dir
935
+
936
+ click.echo(" Installing OpenClaw plugin with required unsafe-install flag...")
937
+ install_result = subprocess.run(
938
+ install_cmd,
939
+ cwd=str(install_cwd) if install_cwd else None,
940
+ capture_output=True,
941
+ text=True,
942
+ encoding="utf-8",
943
+ errors="replace",
944
+ )
945
+ if install_result.returncode != 0:
946
+ combined_error = "\n".join(
947
+ x for x in [install_result.stderr.strip(), install_result.stdout.strip()] if x
948
+ )
949
+ linked_install_bug = (
950
+ "also not a valid hook pack" in combined_error.lower()
951
+ and "--dangerously-force-unsafe-install" in " ".join(install_cmd)
952
+ )
953
+ if linked_install_bug:
954
+ click.echo(
955
+ " OpenClaw linked-path install bug detected; applying extension-path fallback..."
956
+ )
957
+ target_dir = _copy_openclaw_plugin_into_extensions(
958
+ plugin_dir=plugin_dir,
959
+ openclaw_bin=openclaw_bin,
960
+ )
961
+ click.echo(f" Fallback plugin copy completed: {target_dir}")
962
+ else:
963
+ details = combined_error or f"exit code {install_result.returncode}"
964
+ raise click.ClickException(f"openclaw plugins install failed: {details}")
965
+ elif verbose and install_result.stdout.strip():
966
+ click.echo(install_result.stdout.strip())
967
+
968
+ plugin_config: dict[str, object] = {
969
+ "proxyPort": proxy_port,
970
+ "autoStart": not no_auto_start,
971
+ "startupTimeoutMs": startup_timeout_ms,
972
+ }
973
+ if python_path:
974
+ plugin_config["pythonPath"] = python_path
975
+ entry = {"enabled": True, "config": plugin_config}
976
+
977
+ click.echo(" Writing plugin configuration...")
978
+ _run_checked(
979
+ [
980
+ openclaw_bin,
981
+ "config",
982
+ "set",
983
+ "plugins.entries.headroom",
984
+ json.dumps(entry, separators=(",", ":")),
985
+ "--strict-json",
986
+ ],
987
+ action="openclaw config set plugins.entries.headroom",
988
+ )
989
+ _run_checked(
990
+ [
991
+ openclaw_bin,
992
+ "config",
993
+ "set",
994
+ "plugins.slots.contextEngine",
995
+ json.dumps("headroom"),
996
+ "--strict-json",
997
+ ],
998
+ action="openclaw config set plugins.slots.contextEngine",
999
+ )
1000
+ _run_checked(
1001
+ [openclaw_bin, "config", "validate"],
1002
+ action="openclaw config validate",
1003
+ )
1004
+
1005
+ if no_restart:
1006
+ click.echo(" Skipping gateway restart (--no-restart).")
1007
+ click.echo(" Run `openclaw gateway restart` to apply plugin changes.")
1008
+ else:
1009
+ click.echo(" Warning: restarting OpenClaw gateway to apply plugin changes.")
1010
+ restart_result = _run_checked(
1011
+ [openclaw_bin, "gateway", "restart"],
1012
+ action="openclaw gateway restart",
1013
+ )
1014
+ if verbose and restart_result.stdout.strip():
1015
+ click.echo(restart_result.stdout.strip())
1016
+
1017
+ inspect_result = _run_checked(
1018
+ [openclaw_bin, "plugins", "inspect", "headroom"],
1019
+ action="openclaw plugins inspect headroom",
1020
+ )
1021
+ if verbose and inspect_result.stdout.strip():
1022
+ click.echo(inspect_result.stdout.strip())
1023
+
1024
+ click.echo()
1025
+ click.echo("✓ OpenClaw is configured to use Headroom context compression.")
1026
+ click.echo(" Plugin: headroom")
1027
+ click.echo(" Slot: plugins.slots.contextEngine = headroom")
1028
+ click.echo()
plugins/openclaw/README.md CHANGED
@@ -4,6 +4,14 @@ Context compression plugin for [OpenClaw](https://github.com/openclaw/openclaw).
4
 
5
  ## Install
6
 
 
 
 
 
 
 
 
 
7
  ```bash
8
  pip install "headroom-ai[proxy]"
9
  openclaw plugins install --dangerously-force-unsafe-install headroom-ai/openclaw
 
4
 
5
  ## Install
6
 
7
+ Recommended one-command setup:
8
+
9
+ ```bash
10
+ headroom wrap openclaw
11
+ ```
12
+
13
+ Manual install:
14
+
15
  ```bash
16
  pip install "headroom-ai[proxy]"
17
  openclaw plugins install --dangerously-force-unsafe-install headroom-ai/openclaw
tests/test_cli/test_wrap_openclaw.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for `headroom wrap openclaw` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from unittest.mock import MagicMock, patch
8
+
9
+ import pytest
10
+ from click.testing import CliRunner
11
+
12
+ from headroom.cli.main import main
13
+
14
+
15
+ @pytest.fixture
16
+ def runner() -> CliRunner:
17
+ return CliRunner()
18
+
19
+
20
+ @pytest.fixture
21
+ def plugin_dir(tmp_path: Path) -> Path:
22
+ """Create a minimal OpenClaw plugin directory fixture."""
23
+ plugin = tmp_path / "plugins" / "openclaw"
24
+ plugin.mkdir(parents=True)
25
+ (plugin / "package.json").write_text('{"name":"headroom-openclaw"}\n')
26
+ (plugin / "openclaw.plugin.json").write_text('{"id":"headroom"}\n')
27
+ return plugin
28
+
29
+
30
+ def _make_successful_run(calls: list[dict]) -> object:
31
+ def run(cmd, **kwargs): # noqa: ANN001
32
+ calls.append({"cmd": list(cmd), **kwargs})
33
+ return MagicMock(returncode=0, stdout="", stderr="")
34
+
35
+ return run
36
+
37
+
38
+ def test_wrap_openclaw_happy_path_installs_builds_configures_and_restarts(
39
+ runner: CliRunner, plugin_dir: Path
40
+ ) -> None:
41
+ calls: list[dict] = []
42
+
43
+ def which(name: str) -> str | None:
44
+ mapping = {
45
+ "openclaw": "openclaw",
46
+ "npm": "npm",
47
+ }
48
+ return mapping.get(name)
49
+
50
+ with patch("headroom.cli.wrap.shutil.which", side_effect=which):
51
+ with patch("headroom.cli.wrap._default_openclaw_plugin_dir", return_value=plugin_dir):
52
+ with patch("headroom.cli.wrap.subprocess.run", side_effect=_make_successful_run(calls)):
53
+ result = runner.invoke(main, ["wrap", "openclaw"])
54
+
55
+ assert result.exit_code == 0, result.output
56
+
57
+ cmds = [c["cmd"] for c in calls]
58
+ assert ["npm", "install"] in cmds
59
+ assert ["npm", "run", "build"] in cmds
60
+ assert [
61
+ "openclaw",
62
+ "plugins",
63
+ "install",
64
+ "--dangerously-force-unsafe-install",
65
+ "--link",
66
+ ".",
67
+ ] in cmds
68
+ assert ["openclaw", "config", "validate"] in cmds
69
+ assert ["openclaw", "gateway", "restart"] in cmds
70
+ assert ["openclaw", "plugins", "inspect", "headroom"] in cmds
71
+
72
+ # Verify plugin install uses plugin cwd when linking
73
+ install_call = next(
74
+ c
75
+ for c in calls
76
+ if c["cmd"][:4]
77
+ == ["openclaw", "plugins", "install", "--dangerously-force-unsafe-install"]
78
+ )
79
+ assert install_call["cwd"] == str(plugin_dir)
80
+
81
+ # Verify config payload includes enabled + expected defaults
82
+ set_entry = next(c for c in calls if c["cmd"][:4] == ["openclaw", "config", "set", "plugins.entries.headroom"])
83
+ payload = json.loads(set_entry["cmd"][4])
84
+ assert payload["enabled"] is True
85
+ assert payload["config"]["proxyPort"] == 8787
86
+ assert payload["config"]["autoStart"] is True
87
+ assert payload["config"]["startupTimeoutMs"] == 20000
88
+
89
+
90
+ def test_wrap_openclaw_skip_build_and_no_restart(runner: CliRunner, plugin_dir: Path) -> None:
91
+ calls: list[dict] = []
92
+
93
+ def which(name: str) -> str | None:
94
+ mapping = {
95
+ "openclaw": "openclaw",
96
+ "npm": "npm",
97
+ }
98
+ return mapping.get(name)
99
+
100
+ with patch("headroom.cli.wrap.shutil.which", side_effect=which):
101
+ with patch("headroom.cli.wrap.subprocess.run", side_effect=_make_successful_run(calls)):
102
+ result = runner.invoke(
103
+ main,
104
+ [
105
+ "wrap",
106
+ "openclaw",
107
+ "--plugin-path",
108
+ str(plugin_dir),
109
+ "--skip-build",
110
+ "--no-restart",
111
+ ],
112
+ )
113
+
114
+ assert result.exit_code == 0, result.output
115
+ cmds = [c["cmd"] for c in calls]
116
+ assert ["npm", "install"] not in cmds
117
+ assert ["npm", "run", "build"] not in cmds
118
+ assert ["openclaw", "gateway", "restart"] not in cmds
119
+
120
+
121
+ def test_wrap_openclaw_fails_when_openclaw_missing(runner: CliRunner, plugin_dir: Path) -> None:
122
+ def which(name: str) -> str | None:
123
+ return None if name == "openclaw" else "npm"
124
+
125
+ with patch("headroom.cli.wrap.shutil.which", side_effect=which):
126
+ result = runner.invoke(main, ["wrap", "openclaw", "--plugin-path", str(plugin_dir)])
127
+
128
+ assert result.exit_code != 0
129
+ assert "'openclaw' not found in PATH" in result.output
130
+
131
+
132
+ def test_wrap_openclaw_fails_when_plugin_path_invalid(runner: CliRunner, tmp_path: Path) -> None:
133
+ invalid = tmp_path / "missing-plugin"
134
+
135
+ with patch("headroom.cli.wrap.shutil.which", return_value="openclaw"):
136
+ result = runner.invoke(main, ["wrap", "openclaw", "--plugin-path", str(invalid)])
137
+
138
+ assert result.exit_code != 0
139
+ assert "Plugin path not found" in result.output
140
+
141
+
142
+ def test_wrap_openclaw_uses_extension_fallback_on_linked_install_bug(
143
+ runner: CliRunner, plugin_dir: Path
144
+ ) -> None:
145
+ calls: list[dict] = []
146
+
147
+ def which(name: str) -> str | None:
148
+ mapping = {
149
+ "openclaw": "openclaw",
150
+ "npm": "npm",
151
+ }
152
+ return mapping.get(name)
153
+
154
+ def run(cmd, **kwargs): # noqa: ANN001
155
+ calls.append({"cmd": list(cmd), **kwargs})
156
+ if cmd[:3] == ["openclaw", "plugins", "install"]:
157
+ return MagicMock(
158
+ returncode=1,
159
+ stdout="Also not a valid hook pack",
160
+ stderr='Plugin installation blocked despite "--dangerously-force-unsafe-install"',
161
+ )
162
+ return MagicMock(returncode=0, stdout="", stderr="")
163
+
164
+ with patch("headroom.cli.wrap.shutil.which", side_effect=which):
165
+ with patch("headroom.cli.wrap.subprocess.run", side_effect=run):
166
+ with patch(
167
+ "headroom.cli.wrap._copy_openclaw_plugin_into_extensions",
168
+ return_value=Path("C:/Users/test/.openclaw/extensions/headroom"),
169
+ ) as copy_fallback:
170
+ result = runner.invoke(main, ["wrap", "openclaw", "--plugin-path", str(plugin_dir)])
171
+
172
+ assert result.exit_code == 0, result.output
173
+ copy_fallback.assert_called_once()