Spaces:
Build error
Build error
Commit ·
c6897fd
1
Parent(s): 0123b2a
feat(cli): add one-command OpenClaw wrap bootstrap
Browse files- README.md +2 -1
- docs/index.md +2 -1
- headroom/cli/wrap.py +282 -0
- plugins/openclaw/README.md +8 -0
- tests/test_cli/test_wrap_openclaw.py +173 -0
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** |
|
| 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 |
-
|
| 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()
|