Merge pull request #139 from JerrettDavis/feat/docker-native-cli

feat(cli): add Docker-native install flow and parity docs
This commit is contained in:
Tejas Chopra 2026-04-11 09:05:00 -07:00 committed by GitHub
commit 9f124c99ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 3538 additions and 74 deletions

4
.gitignore vendored
View file

@ -1,5 +1,9 @@
# Private scripts (contain credentials)
scripts/
!scripts/
scripts/*
!scripts/install.sh
!scripts/install.ps1
# Swift SDK (separate repo)
swift/

View file

@ -82,6 +82,16 @@ pip install "headroom-ai[all]"
npm install headroom-ai
```
**Docker-native (no Python or Node on host):**
```bash
curl -fsSL https://raw.githubusercontent.com/chopratejas/headroom/main/scripts/install.sh | bash
```
PowerShell:
```powershell
irm https://raw.githubusercontent.com/chopratejas/headroom/main/scripts/install.ps1 | iex
```
### Any agent — one function
**Python:**
@ -127,6 +137,8 @@ Use `cache` mode for long-running chats where preserving prior-turn bytes improv
Works with any language, any tool, any framework. **[Proxy docs](docs/proxy.md)**
Prefer Docker as the runtime provider? See **[Docker-native install](docs/docker-install.md)**.
### Coding agents — one command
```bash
@ -143,6 +155,8 @@ headroom wrap codex --memory # Shares the same memory store
Headroom starts a proxy, points your tool at it, and compresses everything automatically. Add `--memory` for persistent memory that's shared across agents.
In Docker-native mode, Headroom still runs in Docker while wrapped tools run on the host. `wrap claude`, `wrap codex`, `wrap aider`, `wrap cursor`, and OpenClaw plugin setup (`wrap openclaw` / `unwrap openclaw`) are host-managed through the installed wrapper.
### Multi-agent — SharedContext
```python

View file

@ -0,0 +1,33 @@
services:
cli:
image: ${HEADROOM_IMAGE:-ghcr.io/chopratejas/headroom:latest}
entrypoint: ["headroom"]
working_dir: /workspace
stdin_open: true
tty: true
environment:
HOME: /tmp/headroom-home
volumes:
- ${HEADROOM_WORKSPACE:-.}:/workspace
- ${HEADROOM_HOST_HOME:?set HEADROOM_HOST_HOME}/.headroom:/tmp/headroom-home/.headroom
- ${HEADROOM_HOST_HOME:?set HEADROOM_HOST_HOME}/.claude:/tmp/headroom-home/.claude
- ${HEADROOM_HOST_HOME:?set HEADROOM_HOST_HOME}/.codex:/tmp/headroom-home/.codex
- ${HEADROOM_HOST_HOME:?set HEADROOM_HOST_HOME}/.gemini:/tmp/headroom-home/.gemini
command: ["--help"]
proxy:
image: ${HEADROOM_IMAGE:-ghcr.io/chopratejas/headroom:latest}
entrypoint: ["headroom", "proxy"]
working_dir: /workspace
environment:
HOME: /tmp/headroom-home
HEADROOM_HOST: 0.0.0.0
ports:
- "${HEADROOM_PORT:-8787}:${HEADROOM_PORT:-8787}"
volumes:
- ${HEADROOM_WORKSPACE:-.}:/workspace
- ${HEADROOM_HOST_HOME:?set HEADROOM_HOST_HOME}/.headroom:/tmp/headroom-home/.headroom
- ${HEADROOM_HOST_HOME:?set HEADROOM_HOST_HOME}/.claude:/tmp/headroom-home/.claude
- ${HEADROOM_HOST_HOME:?set HEADROOM_HOST_HOME}/.codex:/tmp/headroom-home/.codex
- ${HEADROOM_HOST_HOME:?set HEADROOM_HOST_HOME}/.gemini:/tmp/headroom-home/.gemini
command: ["--host", "0.0.0.0", "--port", "${HEADROOM_PORT:-8787}"]

711
docs/cli.md Normal file
View file

@ -0,0 +1,711 @@
# CLI Reference
This page is the authoritative reference for the **Python Headroom CLI** exposed by the `headroom` console script.
## Global behavior
### Entry points
- Console script: `headroom`
- Python module entrypoint: `python -m headroom.cli`
### Global options
| Option | Scope | Meaning |
|---|---|---|
| `--help`, `-?` | root, groups, commands | Show help and exit |
| `--version`, `-v` | root only | Show the Headroom version and exit |
> `-v` is a **root-level version alias**. Inside subcommands such as `headroom wrap claude -v`, `-v` keeps its subcommand meaning (`--verbose`), not version.
## Command index
| Command | Purpose | Docker-native parity |
|---|---|---|
| `headroom proxy` | Run the Headroom proxy server | **native in container** |
| `headroom learn` | Learn from past tool-call failures | **native in container** |
| `headroom perf` | Summarize recent proxy performance | **native in container** |
| `headroom evals ...` | Run memory evaluation workflows | **native in container** |
| `headroom memory ...` | Inspect and manage stored memories | **native in container** |
| `headroom mcp ...` | Install, inspect, remove, or serve MCP integration | **native in container** |
| `headroom wrap claude` | Start proxy and launch Claude Code | **host-bridged** |
| `headroom wrap codex` | Start proxy and launch Codex CLI | **host-bridged** |
| `headroom wrap aider` | Start proxy and launch Aider | **host-bridged** |
| `headroom wrap cursor` | Start proxy and print Cursor config guidance | **host-bridged** |
| `headroom wrap openclaw` | Install and configure the OpenClaw plugin | **host-bridged** |
| `headroom unwrap openclaw` | Disable the Headroom OpenClaw plugin | **host-bridged** |
## Captured `--help` output
The sections below capture the current top-level help output from the live CLI.
### `headroom --help`
```text
Usage: headroom [OPTIONS] COMMAND [ARGS]...
Headroom - The Context Optimization Layer for LLM Applications.
Manage memories, run the optimization proxy, and analyze metrics.
Examples:
headroom proxy Start the optimization proxy
headroom memory list List stored memories
headroom memory stats Show memory statistics
Options:
-v, --version Show the version and exit.
-?, --help Show this message and exit.
Commands:
evals Memory evaluation commands.
learn Learn from past tool call failures to prevent future ones.
mcp MCP server for Claude Code integration.
memory Manage memories stored in Headroom.
perf Analyze proxy performance from logs.
proxy Start the optimization proxy server.
unwrap Undo durable Headroom wrapping for supported tools.
wrap Wrap CLI tools to run through Headroom.
```
### Top-level command help snapshots
<details>
<summary><code>headroom proxy --help</code></summary>
```text
Usage: headroom proxy [OPTIONS]
Start the optimization proxy server.
Examples:
headroom proxy Start proxy on port 8787
headroom proxy --port 8080 Start proxy on port 8080
headroom proxy --no-optimize Passthrough mode (no optimization)
Usage with Claude Code:
ANTHROPIC_BASE_URL=http://localhost:8787 claude
Usage with OpenAI-compatible clients:
OPENAI_BASE_URL=http://localhost:8787/v1 your-app
```
</details>
<details>
<summary><code>headroom learn --help</code></summary>
```text
Usage: headroom learn [OPTIONS]
Learn from past tool call failures to prevent future ones.
```
</details>
<details>
<summary><code>headroom perf --help</code></summary>
```text
Usage: headroom perf [OPTIONS]
Analyze proxy performance from logs.
```
</details>
<details>
<summary><code>headroom evals --help</code></summary>
```text
Usage: headroom evals [OPTIONS] COMMAND [ARGS]...
Memory evaluation commands.
Commands:
memory Run LoCoMo memory evaluation benchmark.
memory-v2 Run LoCoMo V2 evaluation with LLM-controlled memory tools.
```
</details>
<details>
<summary><code>headroom memory --help</code></summary>
```text
Usage: headroom memory [OPTIONS] COMMAND [ARGS]...
Manage memories stored in Headroom.
Commands:
delete Delete one or more memories by ID.
edit Edit a memory's content or importance.
export Export all memories to JSON.
import Import memories from a JSON file.
list List stored memories with optional filters.
prune Prune memories matching specified criteria.
purge Delete ALL memories from the database.
show Show full details of a single memory.
stats Show memory store statistics.
```
</details>
<details>
<summary><code>headroom mcp --help</code></summary>
```text
Usage: headroom mcp [OPTIONS] COMMAND [ARGS]...
MCP server for Claude Code integration.
Commands:
install Install Headroom MCP server into Claude Code config.
serve Start the MCP server (called by Claude Code).
status Check Headroom MCP configuration status.
uninstall Remove Headroom MCP server from Claude Code config.
```
</details>
<details>
<summary><code>headroom wrap --help</code></summary>
```text
Usage: headroom wrap [OPTIONS] COMMAND [ARGS]...
Wrap CLI tools to run through Headroom.
Commands:
aider Launch aider through Headroom proxy.
claude Launch Claude Code through Headroom proxy.
codex Launch OpenAI Codex CLI through Headroom proxy.
cursor Start Headroom proxy for use with Cursor.
openclaw Install and configure Headroom OpenClaw plugin in one command.
```
</details>
<details>
<summary><code>headroom unwrap --help</code></summary>
```text
Usage: headroom unwrap [OPTIONS] COMMAND [ARGS]...
Undo durable Headroom wrapping for supported tools.
Commands:
openclaw Disable the Headroom OpenClaw plugin and restore the legacy engine slot.
```
</details>
## `headroom proxy`
Start the optimization proxy server.
```bash
headroom proxy
headroom proxy --port 8787
headroom proxy --mode cache
```
| Option | Default | Meaning |
|---|---|---|
| `--host` | `127.0.0.1` | Host interface to bind |
| `--port`, `-p` | `8787` | Port to bind |
| `--mode` | runtime default | Optimization mode: `token`, `cache`, `token_mode`, `cache_mode`, `token_savings`, `cost_savings`, `token_headroom` |
| `--no-optimize` | off | Disable optimization and operate in passthrough mode |
| `--no-cache` | off | Disable semantic caching |
| `--no-rate-limit` | off | Disable rate limiting |
| `--retry-max-attempts` | runtime default `3` | Maximum upstream retry attempts |
| `--connect-timeout-seconds` | runtime default `10` | Upstream connection timeout |
| `--log-file` | unset | JSONL log output path |
| `--budget` | unset | Daily USD budget limit |
| `--no-code-aware` | off | Disable AST-aware code compression |
| `--no-read-lifecycle` | off | Disable stale/superseded read compression |
| `--no-intelligent-context` | off | Disable intelligent context manager |
| `--no-intelligent-scoring` | off | Disable multi-factor importance scoring |
| `--no-compress-first` | off | Disable deep compression before dropping messages |
| `--memory` | off | Enable persistent user memory |
| `--memory-db-path` | `""` | Override memory DB path (help text: `{cwd}/.headroom/memory.db`) |
| `--no-memory-tools` | off | Disable automatic memory tool injection |
| `--no-memory-context` | off | Disable automatic memory context injection |
| `--memory-top-k` | `10` | Number of memories to inject |
| `--learn` | off | Enable live traffic learning |
| `--no-learn` | off | Explicitly disable traffic learning |
| `--backend` | `anthropic` | Backend: `anthropic`, `bedrock`, `openrouter`, `anyllm`, or `litellm-*` |
| `--anyllm-provider` | `openai` | Provider name for `anyllm` |
| `--anthropic-api-url` | unset | Custom Anthropic passthrough API URL |
| `--openai-api-url` | unset | Custom OpenAI passthrough API URL |
| `--gemini-api-url` | unset | Custom Gemini passthrough API URL |
| `--region` | `us-west-2` | Cloud region for Bedrock / Vertex / related backends |
| `--bedrock-region` | unset | Deprecated Bedrock region override |
| `--bedrock-profile` | unset | AWS profile name for Bedrock |
| `--no-telemetry` | off | Disable anonymous usage telemetry |
Notes:
- `--learn` implies memory unless `--no-learn` is also set.
- Proxy startup can also read environment variables such as `HEADROOM_HOST`, `HEADROOM_PORT`, `HEADROOM_BUDGET`, `HEADROOM_MODE`, `HEADROOM_ANYLLM_PROVIDER`, `ANTHROPIC_TARGET_API_URL`, `OPENAI_TARGET_API_URL`, and `GEMINI_TARGET_API_URL`.
See also: [Proxy Server](proxy.md), [Configuration](configuration.md)
## `headroom learn`
Learn from past tool-call failures and produce agent guidance.
```bash
headroom learn
headroom learn --apply
headroom learn --agent codex --all
```
| Option | Default | Meaning |
|---|---|---|
| `--project` | current project resolution | Target project path |
| `--all` | off | Analyze all discovered projects |
| `--apply` | off | Write recommendations instead of dry-run output |
| `--agent` | `auto` | Agent source: `auto`, built-ins (`claude`, `codex`, `gemini`), or plugin-provided names |
| `--model` | auto-detect | LLM model used for analysis |
Notes:
- `--agent auto` scans all detected agent data sources.
- If `--project` is omitted, Headroom resolves from the current directory upward.
- External agent integrations register through the `headroom.learn_plugin` entry point.
See also: [Failure Learning](learn.md)
## `headroom perf`
Summarize recent proxy performance from the local proxy log.
```bash
headroom perf
headroom perf --hours 24
headroom perf --raw
```
| Option | Default | Meaning |
|---|---|---|
| `--hours` | `168.0` | Time window in hours |
| `--raw` | off | Print raw PERF records instead of the summarized report |
The command reads `~/.headroom/logs/proxy.log`.
## `headroom evals`
Memory evaluation command group.
### `headroom evals memory`
Run the LoCoMo memory evaluation benchmark.
```bash
headroom evals memory -n 3
headroom evals memory --answer-model gpt-4o --llm-judge
```
| Option | Default | Meaning |
|---|---|---|
| `--n-conversations`, `-n` | all available | Number of conversations to evaluate |
| `--categories` | benchmark default | Comma-separated categories |
| `--include-adversarial` | off | Include category 5 / unanswerable questions |
| `--top-k` | `10` | Memories retrieved per question |
| `--f1-threshold` | `0.5` | Threshold for correctness |
| `--answer-model` | unset | Model for answer generation |
| `--llm-judge` | off | Use LLM-as-judge scoring |
| `--judge-provider` | `litellm` | Judge provider: `openai`, `anthropic`, `litellm`, `simple` |
| `--judge-model` | `gpt-4o` | Judge model |
| `--output`, `-o` | unset | Save JSON results to a path |
| `--no-extract` | off | Disable LLM memory extraction |
| `--extraction-model` | `gpt-4o-mini` | Memory extraction model |
| `--pass-all` | off | Require all checks to pass |
| `--parallel` | `10` | Parallel worker count |
| `--debug` | off | Enable debug output |
### `headroom evals memory-v2`
Run the V2 memory evaluation flow with LLM-controlled tools.
```bash
headroom evals memory-v2
headroom evals memory-v2 --save-model gpt-4o-mini --llm-judge
```
| Option | Default | Meaning |
|---|---|---|
| `--n-conversations`, `-n` | all available | Number of conversations to evaluate |
| `--categories` | benchmark default | Comma-separated categories |
| `--include-adversarial` | off | Include adversarial questions |
| `--f1-threshold` | `0.5` | Threshold for correctness |
| `--save-model` | `gpt-4o-mini` | Model used when persisting memories |
| `--answer-model` | `gpt-4o` | Answer model |
| `--max-results` | `10` | Maximum tool results |
| `--no-graph` | off | Disable graph usage |
| `--llm-judge` | off | Use LLM-as-judge scoring |
| `--judge-model` | `gpt-4o` | Judge model |
| `--output`, `-o` | unset | Save JSON results |
| `--parallel` | `5` | Parallel worker count |
| `--debug` | off | Enable debug output |
Hidden compatibility shims exist for older command paths:
- `headroom memory-eval`
- `headroom memory-eval-v2`
These are intentionally omitted from normal usage docs.
## `headroom memory`
Memory management command group. This group is only registered when the optional memory dependencies import successfully.
### `headroom memory list`
```bash
headroom memory list
headroom memory list --scope USER --since 7d
headroom memory list -q "budget"
```
| Option | Default | Meaning |
|---|---|---|
| `--db-path` | `headroom_memory.db` | Memory database path |
| `--limit`, `-n` | `50` | Maximum memories to show |
| `--session`, `-s` | unset | Filter by session ID |
| `--scope` | unset | `USER`, `SESSION`, `AGENT`, or `TURN` |
| `--since` | unset | Age filter using duration syntax such as `7d`, `2w`, `1m` |
| `--search`, `-q` | unset | Content search query |
### `headroom memory show <memory_id>`
```bash
headroom memory show 1234abcd
headroom memory show 1234abcd --json
```
| Argument / option | Default | Meaning |
|---|---|---|
| `memory_id` | required | Full or partial memory ID |
| `--db-path` | `headroom_memory.db` | Memory database path |
| `--json` | off | Emit raw JSON |
### `headroom memory stats`
```bash
headroom memory stats
```
| Option | Default | Meaning |
|---|---|---|
| `--db-path` | `headroom_memory.db` | Memory database path |
### `headroom memory edit <memory_id>`
```bash
headroom memory edit 1234abcd --content "Updated note"
headroom memory edit 1234abcd --importance 0.9
```
| Argument / option | Default | Meaning |
|---|---|---|
| `memory_id` | required | Full or partial memory ID |
| `--db-path` | `headroom_memory.db` | Memory database path |
| `--content`, `-c` | unset | New memory content |
| `--importance`, `-i` | unset | New importance score (`0.0` to `1.0`) |
At least one of `--content` or `--importance` is required.
### `headroom memory delete <memory_ids...>`
```bash
headroom memory delete 1234abcd 5678efgh
headroom memory delete 1234abcd --force
```
| Argument / option | Default | Meaning |
|---|---|---|
| `memory_ids...` | required | One or more memory IDs |
| `--db-path` | `headroom_memory.db` | Memory database path |
| `--force`, `-f` | off | Skip confirmation |
### `headroom memory prune`
```bash
headroom memory prune --older-than 30d --dry-run
headroom memory prune --scope SESSION --force
```
| Option | Default | Meaning |
|---|---|---|
| `--db-path` | `headroom_memory.db` | Memory database path |
| `--older-than` | unset | Age threshold |
| `--scope` | unset | Scope filter: `USER`, `SESSION`, `AGENT`, `TURN` |
| `--low-importance` | unset | Importance cutoff |
| `--session`, `-s` | unset | Session ID filter |
| `--dry-run` | off | Show what would be removed |
| `--force`, `-f` | off | Skip confirmation |
At least one filter is required. Filters combine with **AND** semantics.
### `headroom memory purge`
```bash
headroom memory purge --confirm
```
| Option | Default | Meaning |
|---|---|---|
| `--db-path` | `headroom_memory.db` | Memory database path |
| `--confirm` | off | Required confirmation flag |
### `headroom memory export`
```bash
headroom memory export
headroom memory export --output export.json
```
| Option | Default | Meaning |
|---|---|---|
| `--db-path` | `headroom_memory.db` | Memory database path |
| `--output`, `-o` | stdout | Output path |
### `headroom memory import <file>`
```bash
headroom memory import export.json
headroom memory import export.json --force
```
| Argument / option | Default | Meaning |
|---|---|---|
| `file` | required | JSON file containing exported memories |
| `--db-path` | `headroom_memory.db` | Memory database path |
| `--force`, `-f` | off | Skip confirmation |
The import expects a JSON array. Malformed entries are skipped.
## `headroom mcp`
Manage the Headroom MCP server integration.
### `headroom mcp install`
```bash
headroom mcp install
headroom mcp install --proxy-url http://127.0.0.1:9000
```
| Option | Default | Meaning |
|---|---|---|
| `--proxy-url` | `http://127.0.0.1:8787` | Proxy URL written into MCP config |
| `--force` | off | Overwrite an existing Headroom MCP config |
### `headroom mcp uninstall`
```bash
headroom mcp uninstall
```
This removes the Headroom MCP server entry from the Claude configuration.
### `headroom mcp status`
```bash
headroom mcp status
```
This inspects MCP SDK availability, Claude config state, and proxy reachability.
### `headroom mcp serve`
```bash
headroom mcp serve
headroom mcp serve --proxy-url http://127.0.0.1:9000 --debug
```
| Option | Default | Meaning |
|---|---|---|
| `--proxy-url` | `http://127.0.0.1:8787` | Proxy URL (also reads `HEADROOM_PROXY_URL`) |
| `--direct` | off | Disable stdio transport wrapping |
| `--debug` | off | Enable debug logging |
`serve` is part of the public CLI, but it is usually consumed by MCP host tooling rather than by humans directly.
See also: [MCP Tools](mcp.md)
## `headroom wrap`
Wrap external coding tools so their traffic flows through Headroom.
### Shared semantics
- `--port`, when available, defaults to `8787`
- `--no-proxy` skips proxy startup and assumes an existing proxy
- `--learn` enables live traffic learning
- `-v`, `--verbose` means **verbose output**
- Hidden `--prepare-only` exists for internal Docker-native bridge flows and is intentionally omitted from normal usage
### `headroom wrap claude`
```bash
headroom wrap claude
headroom wrap claude --resume <session-id>
headroom wrap claude --port 9999
```
| Option / arg | Default | Meaning |
|---|---|---|
| `--port`, `-p` | `8787` | Proxy port |
| `--no-rtk` | off | Skip `rtk` installation and hook registration |
| `--no-proxy` | off | Reuse an existing proxy |
| `--learn` | off | Enable live traffic learning |
| `--verbose`, `-v` | off | Verbose output |
| `claude_args...` | passthrough | Additional Claude Code arguments |
Requires the `claude` binary on the host.
### `headroom wrap codex`
```bash
headroom wrap codex
headroom wrap codex -- "fix the bug"
headroom wrap codex --backend anyllm --anyllm-provider groq
```
| Option / arg | Default | Meaning |
|---|---|---|
| `--port`, `-p` | `8787` | Proxy port |
| `--no-rtk` | off | Skip `rtk` installation and `AGENTS.md` injection |
| `--no-proxy` | off | Reuse an existing proxy |
| `--learn` | off | Enable live traffic learning |
| `--backend` | unset | Proxy backend override |
| `--anyllm-provider` | unset | `anyllm` provider override |
| `--region` | unset | Cloud region override |
| `--verbose`, `-v` | off | Verbose output |
| `codex_args...` | passthrough | Additional Codex CLI arguments |
Requires the `codex` binary on the host.
### `headroom wrap aider`
```bash
headroom wrap aider
headroom wrap aider -- --model gpt-4o
headroom wrap aider --backend litellm-vertex --region us-central1
```
| Option / arg | Default | Meaning |
|---|---|---|
| `--port`, `-p` | `8787` | Proxy port |
| `--no-rtk` | off | Skip `rtk` installation and `CONVENTIONS.md` injection |
| `--no-proxy` | off | Reuse an existing proxy |
| `--learn` | off | Enable live traffic learning |
| `--backend` | unset | Proxy backend override |
| `--anyllm-provider` | unset | `anyllm` provider override |
| `--region` | unset | Cloud region override |
| `--verbose`, `-v` | off | Verbose output |
| `aider_args...` | passthrough | Additional Aider arguments |
Requires the `aider` binary on the host.
### `headroom wrap cursor`
```bash
headroom wrap cursor
headroom wrap cursor --port 9999
headroom wrap cursor --no-rtk
```
| Option | Default | Meaning |
|---|---|---|
| `--port`, `-p` | `8787` | Proxy port |
| `--no-rtk` | off | Skip `rtk` installation and `.cursorrules` injection |
| `--no-proxy` | off | Reuse an existing proxy |
| `--learn` | off | Enable live traffic learning |
| `--verbose`, `-v` | off | Verbose output |
This command prints Cursor configuration instructions and waits while the proxy stays up. It does **not** launch Cursor directly.
### `headroom wrap openclaw`
```bash
headroom wrap openclaw
headroom wrap openclaw --plugin-path ./plugins/openclaw
```
| Option | Default | Meaning |
|---|---|---|
| `--plugin-path` | unset | Local plugin source directory |
| `--plugin-spec` | `headroom-ai/openclaw` | NPM plugin spec |
| `--skip-build` | off | Skip local `npm install` / build steps |
| `--copy` | off | Copy plugin instead of linked install |
| `--proxy-port` | `8787` | Headroom proxy port |
| `--startup-timeout-ms` | `20000` | Proxy startup timeout |
| `--gateway-provider-id` | repeatable | OpenClaw provider IDs routed through Headroom |
| `--python-path` | unset | Python launcher override |
| `--no-auto-start` | off | Disable plugin auto-start behavior |
| `--no-restart` | off | Do not restart the OpenClaw gateway |
| `--verbose`, `-v` | off | Verbose output |
Requires the `openclaw` binary on the host, and local-source mode may also require `npm`. In Docker-native mode, the installed host wrapper drives the host `openclaw` CLI while the plugin auto-starts the host `headroom` wrapper from `PATH`.
## `headroom unwrap`
Undo durable wrapping for supported tools.
### `headroom unwrap openclaw`
```bash
headroom unwrap openclaw
headroom unwrap openclaw --no-restart
```
| Option | Default | Meaning |
|---|---|---|
| `--no-restart` | off | Do not restart the OpenClaw gateway |
| `--verbose`, `-v` | off | Verbose output |
This disables the Headroom OpenClaw plugin and restores the legacy context engine slot.
## Docker-native parity matrix
This matrix compares the **Python CLI contract** to the Docker-native host wrapper added in this branch.
Legend:
- **native in container** — the command runs entirely inside the Headroom container
- **host-bridged** — Headroom runs in Docker, but the wrapped external tool still runs on the host
| Command path | Python CLI | Docker-native wrapper | Parity |
|---|---|---|---|
| `headroom proxy` | native | native in container | full |
| `headroom learn` | native | native in container | full |
| `headroom perf` | native | native in container | full |
| `headroom evals memory` | native | native in container | full |
| `headroom evals memory-v2` | native | native in container | full |
| `headroom memory ...` | native (when memory deps are available) | native in container | full |
| `headroom mcp install` | native | native in container | full |
| `headroom mcp uninstall` | native | native in container | full |
| `headroom mcp status` | native | native in container | full |
| `headroom mcp serve` | native | native in container | full |
| `headroom wrap claude` | native | host-bridged | partial |
| `headroom wrap codex` | native | host-bridged | partial |
| `headroom wrap aider` | native | host-bridged | partial |
| `headroom wrap cursor` | native | host-bridged | partial |
| `headroom wrap openclaw` | native | host-bridged | partial |
| `headroom unwrap openclaw` | native | host-bridged | partial |
For the Docker-native execution model itself, see [Docker-Native Install](docker-install.md).
## Hidden and compatibility-only command paths
These exist in code but are intentionally excluded from normal user docs:
- `headroom memory-eval`
- `headroom memory-eval-v2`
- hidden internal `--prepare-only` flags on `wrap` subcommands
If you are documenting operational behavior or debugging internal wrapper flows, refer to the implementation in `headroom/cli/wrap.py`.

130
docs/docker-install.md Normal file
View file

@ -0,0 +1,130 @@
# Docker-Native Install
Run Headroom without installing Python or Node.js on the host. The install scripts add a native `headroom` wrapper that keeps **Headroom itself** in Docker while orchestrating the rest of your workflow on the host OS.
## One-line install
### macOS / Linux
```bash
curl -fsSL https://raw.githubusercontent.com/chopratejas/headroom/main/scripts/install.sh | bash
```
### Windows PowerShell
```powershell
irm https://raw.githubusercontent.com/chopratejas/headroom/main/scripts/install.ps1 | iex
```
## What the installer does
1. Verifies Docker is installed and available.
2. Pulls `ghcr.io/chopratejas/headroom:latest`.
3. Installs a `headroom` wrapper into `~/.local/bin` or `~/bin`.
4. Updates shell startup files so the wrapper directory is on `PATH`.
The wrapper keeps Headroom inside Docker and mounts host state back into the container so native behavior stays consistent:
- project workspace -> `/workspace`
- `~/.headroom`
- `~/.claude`
- `~/.codex`
- `~/.gemini`
Port `8787` stays the default, so `http://localhost:8787` works the same way as a native install.
## How the wrapper behaves
### Native Headroom commands
These run directly inside the container:
```bash
headroom proxy
headroom learn
headroom mcp install
headroom memory list
```
For `proxy`, the wrapper publishes the selected port back to the host:
```bash
docker run --rm -it \
-p 8787:8787 \
-v "$PWD:/workspace" \
-w /workspace \
ghcr.io/chopratejas/headroom:latest \
headroom proxy --host 0.0.0.0 --port 8787
```
### `wrap` commands
`wrap` is host-oriented in Docker-native mode:
- the wrapper starts the Headroom proxy in Docker
- container-side prep writes Headroom config, memory, and `rtk` guidance into mounted host files
- the target CLI itself is launched on the host by the wrapper
Supported host wrap flows:
- `headroom wrap claude`
- `headroom wrap codex`
- `headroom wrap aider`
- `headroom wrap cursor`
- `headroom wrap openclaw`
- `headroom unwrap openclaw`
OpenClaw remains host-native in Docker-native mode:
- the host must already have the `openclaw` CLI installed
- `headroom wrap openclaw` installs/configures the Headroom plugin through the host `openclaw` CLI
- plugin auto-start still launches the installed host `headroom` wrapper from `PATH`, which then runs Headroom in Docker
- local plugin source mode (`--plugin-path`) is also supported, but it may require host `npm` when build steps are needed
## Docker Compose support
Use `docker/docker-compose.native.yml` when you want an explicit compose-managed proxy or CLI shell.
### macOS / Linux
```bash
export HEADROOM_HOST_HOME="$HOME"
export HEADROOM_WORKSPACE="$PWD"
docker compose -f docker/docker-compose.native.yml up proxy
```
### Windows PowerShell
```powershell
$env:HEADROOM_HOST_HOME = $HOME
$env:HEADROOM_WORKSPACE = (Get-Location).Path
docker compose -f docker/docker-compose.native.yml up proxy
```
You can also run one-off CLI commands through compose:
```bash
docker compose -f docker/docker-compose.native.yml run --rm cli learn
docker compose -f docker/docker-compose.native.yml run --rm cli mcp install
```
## Environment passthrough
The wrapper forwards Headroom and provider environment variables into the container, including common prefixes such as:
- `HEADROOM_`
- `ANTHROPIC_`
- `OPENAI_`
- `GEMINI_`
- `AWS_`
- `GOOGLE_` / `GOOGLE_CLOUD_`
- `AZURE_`
- `OTEL_`
That keeps provider auth and runtime config working without maintaining a separate env file for the container.
## Notes
- Docker is the only required Headroom runtime dependency on the host.
- Wrapped tools like Claude Code, Codex CLI, Aider, and Cursor still run on the host when you use `headroom wrap ...`.
- The install scripts are idempotent: rerunning them refreshes the wrapper and image without duplicating shell profile blocks.

View file

@ -26,6 +26,20 @@ pip install headroom[all]
npm install headroom-ai
```
**Docker-native:**
```bash
curl -fsSL https://raw.githubusercontent.com/chopratejas/headroom/main/scripts/install.sh | bash
```
PowerShell:
```powershell
irm https://raw.githubusercontent.com/chopratejas/headroom/main/scripts/install.ps1 | iex
```
See [Docker-native install](docker-install.md) for wrapper behavior, compose usage, and host-integrated `wrap` flows.
## Quick Start: Proxy Mode (Recommended)
The easiest way to use Headroom is as a proxy server:

View file

@ -281,8 +281,7 @@ See the [TypeScript SDK Guide](typescript-sdk.md) for full documentation includi
Context compression plugin for [OpenClaw](https://github.com/openclaw/openclaw) agents.
```bash
pip install "headroom-ai[proxy]"
openclaw plugins install headroom-openclaw
headroom wrap openclaw
```
Configure as context engine:
@ -290,6 +289,13 @@ Configure as context engine:
{ "plugins": { "slots": { "contextEngine": "headroom" } } }
```
Manual install remains available when you are not using the CLI wrapper:
```bash
pip install "headroom-ai[proxy]"
openclaw plugins install --dangerously-force-unsafe-install headroom-ai/openclaw
```
The plugin auto-detects a running Headroom proxy or starts one. Compression happens in `assemble()` — zero changes to the agent's behavior.
See the [OpenClaw plugin documentation](https://github.com/chopratejas/headroom/tree/main/plugins/openclaw) for full setup.

View file

@ -25,6 +25,14 @@ pip install "headroom-ai[all]"
npm install headroom-ai
```
**Docker-native:**
```bash
curl -fsSL https://raw.githubusercontent.com/chopratejas/headroom/main/scripts/install.sh | bash
```
See [Docker-native install](docker-install.md) if you want Docker to provide the Headroom runtime while your agent CLIs stay on the host.
---
## Option 1: Proxy Server (Zero Code Changes)

View file

@ -200,7 +200,7 @@ The `headroom-ai` package has no runtime dependencies. Framework SDKs (Vercel AI
## OpenClaw Plugin
The TypeScript SDK powers the [`headroom-openclaw`](https://www.npmjs.com/package/headroom-openclaw) plugin for [OpenClaw](https://github.com/openclaw/openclaw) agents. The plugin uses `HeadroomClient` internally to compress context during the `assemble()` lifecycle hook. Install it with `openclaw plugins install headroom-openclaw`. See the [plugin source](https://github.com/chopratejas/headroom/tree/main/plugins/openclaw) for details.
The TypeScript SDK powers the [`headroom-openclaw`](https://www.npmjs.com/package/headroom-openclaw) plugin for [OpenClaw](https://github.com/openclaw/openclaw) agents. The plugin uses `HeadroomClient` internally to compress context during the `assemble()` lifecycle hook. The preferred install flow is `headroom wrap openclaw`; the direct plugin command is `openclaw plugins install --dangerously-force-unsafe-install headroom-ai/openclaw`. See the [plugin source](https://github.com/chopratejas/headroom/tree/main/plugins/openclaw) for details.
## Comparison with Python SDK

View file

@ -2,6 +2,8 @@
import click
CLI_CONTEXT_SETTINGS = {"help_option_names": ["--help", "-?"]}
def get_version() -> str:
"""Get the current version."""
@ -13,8 +15,8 @@ def get_version() -> str:
return "unknown"
@click.group()
@click.version_option(version=get_version(), prog_name="headroom")
@click.group(context_settings=CLI_CONTEXT_SETTINGS)
@click.version_option(get_version(), "--version", "-v", prog_name="headroom")
@click.pass_context
def main(ctx: click.Context) -> None:
"""Headroom - The Context Optimization Layer for LLM Applications.
@ -51,5 +53,24 @@ def _register_commands() -> None:
_register_commands()
def _apply_help_aliases(command: click.Command) -> None:
"""Ensure `-?` works everywhere in the Click command tree."""
context_settings = dict(command.context_settings or {})
help_option_names = list(context_settings.get("help_option_names", []))
if "--help" not in help_option_names:
help_option_names.append("--help")
if "-?" not in help_option_names:
help_option_names.append("-?")
context_settings["help_option_names"] = help_option_names
command.context_settings = context_settings
if isinstance(command, click.Group):
for child in command.commands.values():
_apply_help_aliases(child)
_apply_help_aliases(main)
if __name__ == "__main__":
main()

View file

@ -10,8 +10,20 @@ from .main import main
@main.command()
@click.option("--host", default="127.0.0.1", help="Host to bind to (default: 127.0.0.1)")
@click.option("--port", "-p", default=8787, type=int, help="Port to bind to (default: 8787)")
@click.option(
"--host",
default="127.0.0.1",
envvar="HEADROOM_HOST",
help="Host to bind to (default: 127.0.0.1, env: HEADROOM_HOST)",
)
@click.option(
"--port",
"-p",
default=8787,
type=int,
envvar="HEADROOM_PORT",
help="Port to bind to (default: 8787, env: HEADROOM_PORT)",
)
@click.option(
"--mode",
default=None,
@ -48,7 +60,13 @@ from .main import main
help="Upstream connection timeout in seconds (default: 10)",
)
@click.option("--log-file", default=None, help="Path to JSONL log file")
@click.option("--budget", type=float, default=None, help="Daily budget limit in USD")
@click.option(
"--budget",
type=float,
default=None,
envvar="HEADROOM_BUDGET",
help="Daily budget limit in USD (env: HEADROOM_BUDGET)",
)
# Code-aware compression (ON by default if installed)
@click.option("--no-code-aware", is_flag=True, help="Disable AST-based code compression")
# Read lifecycle (ON by default: compresses stale/superseded Read outputs)

View file

@ -256,6 +256,13 @@ def _ensure_rtk_binary(verbose: bool = False) -> Path | None:
return None
def _prepare_wrap_rtk(verbose: bool = False, *, label: str | None = None) -> Path | None:
"""Ensure rtk is present for host-bridged wrap flows without host-specific setup."""
if label:
click.echo(f" Preparing rtk for {label}...")
return _ensure_rtk_binary(verbose=verbose)
def _inject_codex_provider_config(port: int) -> None:
"""Inject a Headroom model provider into Codex's config.toml.
@ -585,6 +592,17 @@ def _read_openclaw_config_value(openclaw_bin: str, path: str) -> Any | None:
return output
def _decode_openclaw_entry_json(raw_value: str | None) -> Any | None:
"""Decode a JSON payload captured from `openclaw config get` when available."""
if not raw_value:
return None
try:
return json.loads(raw_value)
except json.JSONDecodeError:
return raw_value
def _build_openclaw_plugin_entry(
*,
existing_entry: Any,
@ -619,6 +637,28 @@ def _build_openclaw_plugin_entry(
}
def _build_openclaw_unwrap_entry(existing_entry: Any) -> dict[str, object]:
"""Disable the managed plugin while preserving unrelated user config."""
base_entry = existing_entry if isinstance(existing_entry, dict) else {}
existing_config = {}
if isinstance(existing_entry, dict) and isinstance(existing_entry.get("config"), dict):
existing_config = {
key: value
for key, value in existing_entry["config"].items()
if key
not in {
"gatewayProviderIds",
"proxyUrl",
"proxyPort",
"autoStart",
"startupTimeoutMs",
"pythonPath",
}
}
return {**base_entry, "enabled": False, "config": existing_config}
def _write_openclaw_plugin_entry(openclaw_bin: str, entry: dict[str, object]) -> None:
"""Persist the Headroom plugin config entry."""
_run_checked(
@ -744,9 +784,16 @@ def unwrap() -> None:
"--learn", is_flag=True, help="Enable live traffic learning (patterns saved to MEMORY.md)"
)
@click.option("--verbose", "-v", is_flag=True, help="Verbose output")
@click.option("--prepare-only", is_flag=True, hidden=True)
@click.argument("claude_args", nargs=-1, type=click.UNPROCESSED)
def claude(
port: int, no_rtk: bool, no_proxy: bool, learn: bool, verbose: bool, claude_args: tuple
port: int,
no_rtk: bool,
no_proxy: bool,
learn: bool,
verbose: bool,
prepare_only: bool,
claude_args: tuple,
) -> None:
"""Launch Claude Code through Headroom proxy.
@ -762,6 +809,11 @@ def claude(
headroom wrap claude --port 9999 # Custom proxy port
headroom wrap claude --no-rtk # Skip rtk (proxy only)
"""
if prepare_only:
if not no_rtk:
_prepare_wrap_rtk(verbose=verbose, label="Claude")
return
claude_bin = shutil.which("claude")
if not claude_bin:
click.echo("Error: 'claude' not found in PATH.")
@ -985,6 +1037,7 @@ def copilot(
"--region", default=None, help="Cloud region for Bedrock/Vertex (env: HEADROOM_REGION)"
)
@click.option("--verbose", "-v", is_flag=True, help="Verbose output")
@click.option("--prepare-only", is_flag=True, hidden=True)
@click.argument("codex_args", nargs=-1, type=click.UNPROCESSED)
def codex(
port: int,
@ -995,6 +1048,7 @@ def codex(
anyllm_provider: str | None,
region: str | None,
verbose: bool,
prepare_only: bool,
codex_args: tuple,
) -> None:
"""Launch OpenAI Codex CLI through Headroom proxy.
@ -1012,12 +1066,6 @@ def codex(
headroom wrap codex --port 9999 # Custom proxy port
headroom wrap codex --backend anyllm --anyllm-provider groq
"""
codex_bin = shutil.which("codex")
if not codex_bin:
click.echo("Error: 'codex' not found in PATH.")
click.echo("Install Codex CLI: npm install -g @openai/codex")
raise SystemExit(1)
# Setup rtk for Codex (binary + AGENTS.md instructions, no hooks)
if not no_rtk:
click.echo(" Setting up rtk for Codex...")
@ -1031,6 +1079,16 @@ def codex(
global_agents = Path.home() / ".codex" / "AGENTS.md"
_inject_rtk_instructions(global_agents, verbose=verbose)
if prepare_only:
_inject_codex_provider_config(port)
return
codex_bin = shutil.which("codex")
if not codex_bin:
click.echo("Error: 'codex' not found in PATH.")
click.echo("Install Codex CLI: npm install -g @openai/codex")
raise SystemExit(1)
env = os.environ.copy()
env["OPENAI_BASE_URL"] = f"http://127.0.0.1:{port}/v1"
@ -1071,6 +1129,7 @@ def codex(
@click.option("--anyllm-provider", default=None, help="Provider for any-llm backend")
@click.option("--region", default=None, help="Cloud region for Bedrock/Vertex")
@click.option("--verbose", "-v", is_flag=True, help="Verbose output")
@click.option("--prepare-only", is_flag=True, hidden=True)
@click.argument("aider_args", nargs=-1, type=click.UNPROCESSED)
def aider(
port: int,
@ -1081,6 +1140,7 @@ def aider(
anyllm_provider: str | None,
region: str | None,
verbose: bool,
prepare_only: bool,
aider_args: tuple,
) -> None:
"""Launch aider through Headroom proxy.
@ -1098,12 +1158,6 @@ def aider(
headroom wrap aider --no-rtk # Skip rtk setup
headroom wrap aider --backend litellm-vertex --region us-central1
"""
aider_bin = shutil.which("aider")
if not aider_bin:
click.echo("Error: 'aider' not found in PATH.")
click.echo("Install aider: pip install aider-chat")
raise SystemExit(1)
# Setup rtk for aider (binary + CONVENTIONS.md instructions)
if not no_rtk:
click.echo(" Setting up rtk for aider...")
@ -1113,6 +1167,15 @@ def aider(
conventions = Path.cwd() / "CONVENTIONS.md"
_inject_rtk_instructions(conventions, verbose=verbose)
if prepare_only:
return
aider_bin = shutil.which("aider")
if not aider_bin:
click.echo("Error: 'aider' not found in PATH.")
click.echo("Install aider: pip install aider-chat")
raise SystemExit(1)
env = os.environ.copy()
env["OPENAI_API_BASE"] = f"http://127.0.0.1:{port}/v1"
env["ANTHROPIC_BASE_URL"] = f"http://127.0.0.1:{port}"
@ -1149,7 +1212,15 @@ def aider(
"--learn", is_flag=True, help="Enable live traffic learning (patterns saved to .cursor/rules/)"
)
@click.option("--verbose", "-v", is_flag=True, help="Verbose output")
def cursor(port: int, no_rtk: bool, no_proxy: bool, learn: bool, verbose: bool) -> None:
@click.option("--prepare-only", is_flag=True, hidden=True)
def cursor(
port: int,
no_rtk: bool,
no_proxy: bool,
learn: bool,
verbose: bool,
prepare_only: bool,
) -> None:
"""Start Headroom proxy for use with Cursor.
\b
@ -1167,6 +1238,16 @@ def cursor(port: int, no_rtk: bool, no_proxy: bool, learn: bool, verbose: bool)
headroom wrap cursor --no-rtk # Proxy only, no rtk
headroom wrap cursor --port 9999 # Custom proxy port
"""
if not no_rtk:
click.echo(" Setting up rtk for Cursor...")
rtk_path = _ensure_rtk_binary(verbose=verbose)
if rtk_path:
cursorrules = Path.cwd() / ".cursorrules"
_inject_rtk_instructions(cursorrules, verbose=verbose)
if prepare_only:
return
proxy_holder: list[subprocess.Popen | None] = [None]
cleanup = _make_cleanup(proxy_holder, port)
signal.signal(signal.SIGINT, cleanup)
@ -1181,14 +1262,6 @@ def cursor(port: int, no_rtk: bool, no_proxy: bool, learn: bool, verbose: bool)
proxy_holder[0] = _ensure_proxy(port, no_proxy, learn=learn, agent_type="cursor")
# Setup rtk for Cursor (binary + .cursorrules instructions)
if not no_rtk:
click.echo(" Setting up rtk for Cursor...")
rtk_path = _ensure_rtk_binary(verbose=verbose)
if rtk_path:
cursorrules = Path.cwd() / ".cursorrules"
_inject_rtk_instructions(cursorrules, verbose=verbose)
click.echo()
click.echo(" Headroom proxy is running. Configure Cursor:")
click.echo()
@ -1283,6 +1356,8 @@ def cursor(port: int, no_rtk: bool, no_proxy: bool, learn: bool, verbose: bool)
help="Do not restart OpenClaw gateway at the end",
)
@click.option("--verbose", "-v", is_flag=True, help="Verbose output")
@click.option("--prepare-only", is_flag=True, hidden=True)
@click.option("--existing-entry-json", default=None, hidden=True)
def openclaw(
plugin_path: Path | None,
plugin_spec: str,
@ -1295,6 +1370,8 @@ def openclaw(
no_auto_start: bool,
no_restart: bool,
verbose: bool,
prepare_only: bool,
existing_entry_json: str | None,
) -> None:
"""Install and configure Headroom OpenClaw plugin in one command.
@ -1311,6 +1388,19 @@ def openclaw(
headroom wrap openclaw
headroom wrap openclaw --plugin-path C:\\git\\headroom\\plugins\\openclaw
"""
if prepare_only:
entry = _build_openclaw_plugin_entry(
existing_entry=_decode_openclaw_entry_json(existing_entry_json),
proxy_port=proxy_port,
startup_timeout_ms=startup_timeout_ms,
python_path=python_path,
no_auto_start=no_auto_start,
gateway_provider_ids=gateway_provider_ids,
enabled=True,
)
click.echo(json.dumps(entry, separators=(",", ":")))
return
openclaw_bin = shutil.which("openclaw")
if not openclaw_bin:
raise click.ClickException("'openclaw' not found in PATH. Install OpenClaw CLI first.")
@ -1455,8 +1545,24 @@ def openclaw(
@unwrap.command("openclaw")
@click.option("--no-restart", is_flag=True, help="Do not restart OpenClaw gateway at the end")
@click.option("--verbose", "-v", is_flag=True, help="Verbose output")
def unwrap_openclaw(no_restart: bool, verbose: bool) -> None:
@click.option("--prepare-only", is_flag=True, hidden=True)
@click.option("--existing-entry-json", default=None, hidden=True)
def unwrap_openclaw(
no_restart: bool,
verbose: bool,
prepare_only: bool,
existing_entry_json: str | None,
) -> None:
"""Disable the Headroom OpenClaw plugin and restore the legacy engine slot."""
if prepare_only:
click.echo(
json.dumps(
_build_openclaw_unwrap_entry(_decode_openclaw_entry_json(existing_entry_json)),
separators=(",", ":"),
)
)
return
openclaw_bin = shutil.which("openclaw")
if not openclaw_bin:
raise click.ClickException("'openclaw' not found in PATH. Install OpenClaw CLI first.")
@ -1469,23 +1575,7 @@ def unwrap_openclaw(no_restart: bool, verbose: bool) -> None:
click.echo(" Disabling Headroom plugin and removing engine mapping...")
existing_entry = _read_openclaw_config_value(openclaw_bin, "plugins.entries.headroom")
existing_config = {}
if isinstance(existing_entry, dict) and isinstance(existing_entry.get("config"), dict):
existing_config = {
key: value
for key, value in existing_entry["config"].items()
if key
not in {
"gatewayProviderIds",
"proxyUrl",
"proxyPort",
"autoStart",
"startupTimeoutMs",
"pythonPath",
}
}
entry = {"enabled": False, "config": existing_config}
entry = _build_openclaw_unwrap_entry(existing_entry)
_write_openclaw_plugin_entry(openclaw_bin, entry)
_set_openclaw_context_engine_slot(openclaw_bin, "legacy")
_run_checked(

View file

@ -16,6 +16,16 @@ _RTK_NAME = "rtk.exe" if platform.system() == "Windows" else "rtk"
RTK_BIN_PATH = RTK_BIN_DIR / _RTK_NAME
def _managed_rtk_candidates() -> list[Path]:
"""Return known Headroom-managed rtk binary paths."""
candidates = [RTK_BIN_DIR / _RTK_NAME]
for name in ("rtk", "rtk.exe"):
path = RTK_BIN_DIR / name
if path not in candidates:
candidates.append(path)
return candidates
def get_rtk_path() -> Path | None:
"""Get path to rtk binary — check PATH first, then ~/.headroom/bin/."""
# Check if rtk is already in PATH (e.g., installed via brew)
@ -24,8 +34,9 @@ def get_rtk_path() -> Path | None:
return Path(system_rtk)
# Check Headroom-managed install
if RTK_BIN_PATH.exists() and RTK_BIN_PATH.is_file():
return RTK_BIN_PATH
for candidate in _managed_rtk_candidates():
if candidate.exists() and candidate.is_file():
return candidate
return None

View file

@ -4,6 +4,7 @@ from __future__ import annotations
import io
import logging
import os
import platform
import stat
import subprocess
@ -19,7 +20,7 @@ logger = logging.getLogger(__name__)
GITHUB_RELEASE_URL = "https://github.com/rtk-ai/rtk/releases/download"
def _get_target_triple() -> str:
def _detect_runtime_target_triple() -> str:
"""Detect platform and return the rtk release target triple."""
system = platform.system()
machine = platform.machine()
@ -37,6 +38,21 @@ def _get_target_triple() -> str:
raise RuntimeError(f"Unsupported platform: {system} {machine}")
def _get_target_triple() -> str:
"""Return the requested rtk target triple, honoring explicit overrides."""
return os.environ.get("HEADROOM_RTK_TARGET", "").strip() or _detect_runtime_target_triple()
def _binary_name_for_target(target: str) -> str:
"""Return the expected binary name for a target triple."""
return "rtk.exe" if "windows" in target else "rtk"
def _should_verify_target(target: str) -> bool:
"""Verify only when the requested target matches the current runtime."""
return target == _detect_runtime_target_triple()
def _get_download_url(version: str) -> tuple[str, str]:
"""Get download URL and extension for this platform.
@ -66,7 +82,9 @@ def download_rtk(version: str | None = None) -> Path:
RuntimeError: If download or extraction fails.
"""
version = version or RTK_VERSION
target = _get_target_triple()
url, ext = _get_download_url(version)
target_path = RTK_BIN_DIR / _binary_name_for_target(target)
RTK_BIN_DIR.mkdir(parents=True, exist_ok=True)
@ -97,7 +115,7 @@ def download_rtk(version: str | None = None) -> Path:
# Find the rtk binary inside the archive
for member in tar.getmembers():
if member.name.endswith("/rtk") or member.name == "rtk":
member.name = "rtk" # Flatten path
member.name = target_path.name # Flatten path
tar.extract(member, RTK_BIN_DIR)
break
else:
@ -106,8 +124,7 @@ def download_rtk(version: str | None = None) -> Path:
with zipfile.ZipFile(io.BytesIO(data)) as zf:
for name in zf.namelist():
if name.endswith("rtk.exe") or name.endswith("/rtk"):
target_name = "rtk.exe" if name.endswith(".exe") else "rtk"
with zf.open(name) as src, open(RTK_BIN_DIR / target_name, "wb") as dst:
with zf.open(name) as src, open(target_path, "wb") as dst:
dst.write(src.read())
break
else:
@ -116,28 +133,30 @@ def download_rtk(version: str | None = None) -> Path:
raise RuntimeError(f"Failed to extract rtk archive: {e}") from e
# Make executable (skip on Windows — no Unix permissions)
if platform.system() != "Windows":
RTK_BIN_PATH.chmod(RTK_BIN_PATH.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
if "windows" not in target:
target_path.chmod(target_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
# Verify
try:
result = subprocess.run(
[str(RTK_BIN_PATH), "--version"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=5,
)
if result.returncode != 0:
raise RuntimeError(f"rtk verification failed: {result.stderr}")
logger.info("rtk installed: %s", result.stdout.strip())
except FileNotFoundError as e:
raise RuntimeError("rtk binary not found after extraction") from e
except subprocess.TimeoutExpired as e:
raise RuntimeError("rtk verification timed out") from e
if _should_verify_target(target):
try:
result = subprocess.run(
[str(target_path), "--version"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=5,
)
if result.returncode != 0:
raise RuntimeError(f"rtk verification failed: {result.stderr}")
logger.info("rtk installed: %s", result.stdout.strip())
except FileNotFoundError as e:
raise RuntimeError("rtk binary not found after extraction") from e
except subprocess.TimeoutExpired as e:
raise RuntimeError("rtk verification timed out") from e
else:
logger.info("rtk installed for target %s at %s (verification skipped)", target, target_path)
return RTK_BIN_PATH
return target_path
def register_claude_hooks(rtk_path: Path | None = None) -> bool:

View file

@ -86,6 +86,7 @@ nav:
- Getting Started:
- Quickstart: quickstart.md
- Installation: getting-started.md
- Docker-Native Install: docker-install.md
- Configuration: configuration.md
- User Guide:
- Proxy Server: proxy.md
@ -111,6 +112,7 @@ nav:
- Latency: LATENCY_BENCHMARKS.md
- Limitations: LIMITATIONS.md
- Reference:
- CLI: cli.md
- API: api.md
- SDK: sdk.md
- Metrics & Observability: metrics.md

View file

@ -150,6 +150,8 @@ When `proxyUrl` points to localhost (or is omitted), the plugin will auto-start
If `pythonPath` is set, it is tried first in the Python fallback step.
Docker-native Headroom installs intentionally leave `pythonPath` unset so this launcher order prefers the installed host `headroom` wrapper on `PATH`, which then runs Headroom in Docker.
### Remote proxy (connect-only)
Point `proxyUrl` to any reachable Headroom instance:

1089
scripts/install.ps1 Normal file

File diff suppressed because it is too large Load diff

977
scripts/install.sh Normal file
View file

@ -0,0 +1,977 @@
#!/usr/bin/env bash
set -euo pipefail
IMAGE_DEFAULT="ghcr.io/chopratejas/headroom:latest"
INSTALL_DIR="${HOME}/.local/bin"
if [[ ! -d "${HOME}/.local" ]]; then
INSTALL_DIR="${HOME}/bin"
fi
info() {
printf '==> %s\n' "$*"
}
warn() {
printf 'WARN: %s\n' "$*" >&2
}
die() {
printf 'ERROR: %s\n' "$*" >&2
exit 1
}
require_cmd() {
command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
}
append_path_block() {
local target_file="$1"
local marker_start="# >>> headroom docker-native >>>"
local marker_end="# <<< headroom docker-native <<<"
local block="${marker_start}
export PATH=\"${INSTALL_DIR}:\$PATH\"
${marker_end}"
touch "${target_file}"
if grep -Fq "${marker_start}" "${target_file}"; then
return
fi
{
printf '\n%s\n' "${block}"
} >>"${target_file}"
}
write_wrapper() {
local wrapper_path="${INSTALL_DIR}/headroom"
cat >"${wrapper_path}" <<'WRAPPER'
#!/usr/bin/env bash
set -euo pipefail
HEADROOM_IMAGE="${HEADROOM_DOCKER_IMAGE:-ghcr.io/chopratejas/headroom:latest}"
HEADROOM_CONTAINER_HOME="${HEADROOM_CONTAINER_HOME:-/tmp/headroom-home}"
HEADROOM_HOST_HOME="${HOME:?}"
warn() {
printf 'WARN: %s\n' "$*" >&2
}
die() {
printf 'ERROR: %s\n' "$*" >&2
exit 1
}
require_cmd() {
command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
}
detect_rtk_target() {
local system
local machine
system="$(uname -s)"
machine="$(uname -m)"
case "${system}" in
Darwin)
if [[ "${machine}" == "arm64" ]]; then
printf 'aarch64-apple-darwin'
else
printf 'x86_64-apple-darwin'
fi
;;
Linux)
if [[ "${machine}" == "aarch64" ]]; then
printf 'aarch64-unknown-linux-gnu'
else
printf 'x86_64-unknown-linux-musl'
fi
;;
*)
die "Unsupported host platform for Docker-native wrapper: ${system}/${machine}"
;;
esac
}
ensure_host_dirs() {
mkdir -p \
"${HEADROOM_HOST_HOME}/.headroom" \
"${HEADROOM_HOST_HOME}/.claude" \
"${HEADROOM_HOST_HOME}/.codex" \
"${HEADROOM_HOST_HOME}/.gemini"
}
append_passthrough_envs() {
local -n ref=$1
local name
for name in $(compgen -e); do
case "${name}" in
HEADROOM_*|ANTHROPIC_*|OPENAI_*|GEMINI_*|AWS_*|AZURE_*|VERTEX_*|GOOGLE_*|GOOGLE_CLOUD_*|MISTRAL_*|GROQ_*|OPENROUTER_*|XAI_*|TOGETHER_*|COHERE_*|OLLAMA_*|LITELLM_*|OTEL_*|SUPABASE_*|QDRANT_*|NEO4J_*|LANGSMITH_*)
ref+=(--env "${name}")
;;
esac
done
}
append_common_container_args() {
local -n ref=$1
ensure_host_dirs
ref+=(-w /workspace)
ref+=(--env "HOME=${HEADROOM_CONTAINER_HOME}")
ref+=(--env "PYTHONUNBUFFERED=1")
ref+=(-v "${PWD}:/workspace")
ref+=(-v "${HEADROOM_HOST_HOME}/.headroom:${HEADROOM_CONTAINER_HOME}/.headroom")
ref+=(-v "${HEADROOM_HOST_HOME}/.claude:${HEADROOM_CONTAINER_HOME}/.claude")
ref+=(-v "${HEADROOM_HOST_HOME}/.codex:${HEADROOM_CONTAINER_HOME}/.codex")
ref+=(-v "${HEADROOM_HOST_HOME}/.gemini:${HEADROOM_CONTAINER_HOME}/.gemini")
if command -v id >/dev/null 2>&1; then
ref+=(--user "$(id -u):$(id -g)")
fi
append_passthrough_envs "$1"
}
append_tty_args() {
local -n ref=$1
if [[ -t 0 && -t 1 ]]; then
ref+=(-it)
elif [[ -t 0 ]]; then
ref+=(-i)
elif [[ -t 1 ]]; then
ref+=(-t)
fi
}
run_headroom() {
local args=()
args=(docker run --rm)
append_tty_args args
append_common_container_args args
args+=(--entrypoint headroom "${HEADROOM_IMAGE}" "$@")
"${args[@]}"
}
docker_container_exists() {
local name="$1"
docker ps --format '{{.Names}}' | grep -Fxq "${name}"
}
wait_for_proxy() {
local container_name="$1"
local port="$2"
local attempt
for attempt in $(seq 1 45); do
if (echo >/dev/tcp/127.0.0.1/"${port}") >/dev/null 2>&1; then
return 0
fi
if ! docker_container_exists "${container_name}"; then
break
fi
sleep 1
done
docker logs "${container_name}" >&2 || true
return 1
}
start_proxy_container() {
local port="$1"
shift
local container_name="headroom-proxy-${port}-$$"
local args=()
args=(docker run -d --rm --name "${container_name}" -p "${port}:${port}")
append_common_container_args args
args+=("${HEADROOM_IMAGE}" --host 0.0.0.0 --port "${port}" "$@")
"${args[@]}" >/dev/null
if ! wait_for_proxy "${container_name}" "${port}"; then
docker stop "${container_name}" >/dev/null 2>&1 || true
die "Headroom proxy failed to start on port ${port}"
fi
printf '%s\n' "${container_name}"
}
stop_proxy_container() {
local container_name="${1:-}"
if [[ -n "${container_name}" ]]; then
docker stop "${container_name}" >/dev/null 2>&1 || true
fi
}
run_claude_rtk_init() {
local rtk_bin="${HEADROOM_HOST_HOME}/.headroom/bin/rtk"
if [[ ! -x "${rtk_bin}" ]]; then
warn "rtk was not installed at ${rtk_bin}; Claude hooks were not registered"
return
fi
if ! "${rtk_bin}" init --global --auto-patch >/dev/null 2>&1; then
warn "Failed to register Claude hooks with rtk; continuing without hook registration"
fi
}
parse_wrap_args() {
local -n out_known=$1
local -n out_host=$2
local -n out_port=$3
local -n out_no_rtk=$4
local -n out_no_proxy=$5
local -n out_learn=$6
local -n out_backend=$7
local -n out_anyllm=$8
local -n out_region=$9
shift 9
out_known=()
out_host=()
out_port=8787
out_no_rtk=0
out_no_proxy=0
out_learn=0
out_backend=""
out_anyllm=""
out_region=""
while (($#)); do
case "$1" in
--)
shift
out_host+=("$@")
break
;;
--port|-p)
out_port="$2"
out_known+=("$1" "$2")
shift 2
;;
--port=*)
out_port="${1#*=}"
out_known+=("$1")
shift
;;
--no-rtk)
out_no_rtk=1
out_known+=("$1")
shift
;;
--no-proxy)
out_no_proxy=1
out_known+=("$1")
shift
;;
--learn)
out_learn=1
out_known+=("$1")
shift
;;
--verbose|-v)
out_known+=("$1")
shift
;;
--backend)
out_backend="$2"
out_known+=("$1" "$2")
shift 2
;;
--backend=*)
out_backend="${1#*=}"
out_known+=("$1")
shift
;;
--anyllm-provider)
out_anyllm="$2"
out_known+=("$1" "$2")
shift 2
;;
--anyllm-provider=*)
out_anyllm="${1#*=}"
out_known+=("$1")
shift
;;
--region)
out_region="$2"
out_known+=("$1" "$2")
shift 2
;;
--region=*)
out_region="${1#*=}"
out_known+=("$1")
shift
;;
*)
out_host+=("$@")
break
;;
esac
done
}
run_prepare_only() {
local tool="$1"
shift
local args=()
args=(docker run --rm)
append_tty_args args
append_common_container_args args
args+=(--env "HEADROOM_RTK_TARGET=$(detect_rtk_target)")
args+=(--entrypoint headroom "${HEADROOM_IMAGE}" wrap "${tool}" --prepare-only "$@")
"${args[@]}"
}
run_host_tool() {
local binary="$1"
shift
command -v "${binary}" >/dev/null 2>&1 || die "'${binary}' not found in PATH"
"${binary}" "$@"
}
contains_help_flag() {
local arg
for arg in "$@"; do
if [[ "${arg}" == "--" ]]; then
break
fi
if [[ "${arg}" == "--help" || "${arg}" == "-?" ]]; then
return 0
fi
done
return 1
}
parse_openclaw_wrap_args() {
local -n out_plugin_path=$1
local -n out_plugin_spec=$2
local -n out_skip_build=$3
local -n out_copy=$4
local -n out_proxy_port=$5
local -n out_startup_timeout_ms=$6
local -n out_gateway_provider_ids=$7
local -n out_python_path=$8
local -n out_no_auto_start=$9
local -n out_no_restart=${10}
local -n out_verbose=${11}
shift 11
out_plugin_path=""
out_plugin_spec="headroom-ai/openclaw"
out_skip_build=0
out_copy=0
out_proxy_port=8787
out_startup_timeout_ms=20000
out_gateway_provider_ids=()
out_python_path=""
out_no_auto_start=0
out_no_restart=0
out_verbose=0
while (($#)); do
case "$1" in
--plugin-path)
out_plugin_path="$2"
shift 2
;;
--plugin-path=*)
out_plugin_path="${1#*=}"
shift
;;
--plugin-spec)
out_plugin_spec="$2"
shift 2
;;
--plugin-spec=*)
out_plugin_spec="${1#*=}"
shift
;;
--skip-build)
out_skip_build=1
shift
;;
--copy)
out_copy=1
shift
;;
--proxy-port)
out_proxy_port="$2"
shift 2
;;
--proxy-port=*)
out_proxy_port="${1#*=}"
shift
;;
--startup-timeout-ms)
out_startup_timeout_ms="$2"
shift 2
;;
--startup-timeout-ms=*)
out_startup_timeout_ms="${1#*=}"
shift
;;
--gateway-provider-id)
out_gateway_provider_ids+=("$2")
shift 2
;;
--gateway-provider-id=*)
out_gateway_provider_ids+=("${1#*=}")
shift
;;
--python-path)
out_python_path="$2"
shift 2
;;
--python-path=*)
out_python_path="${1#*=}"
shift
;;
--no-auto-start)
out_no_auto_start=1
shift
;;
--no-restart)
out_no_restart=1
shift
;;
--verbose|-v)
out_verbose=1
shift
;;
*)
die "Unsupported option for 'headroom wrap openclaw': $1"
;;
esac
done
}
parse_openclaw_unwrap_args() {
local -n out_no_restart=$1
local -n out_verbose=$2
shift 2
out_no_restart=0
out_verbose=0
while (($#)); do
case "$1" in
--no-restart)
out_no_restart=1
shift
;;
--verbose|-v)
out_verbose=1
shift
;;
*)
die "Unsupported option for 'headroom unwrap openclaw': $1"
;;
esac
done
}
get_openclaw_existing_entry_json() {
local output=""
if output="$(openclaw config get plugins.entries.headroom 2>/dev/null)"; then
printf '%s' "${output}"
fi
}
prepare_openclaw_entry_json() {
local existing_entry_json="$1"
local proxy_port="$2"
local startup_timeout_ms="$3"
local python_path="$4"
local no_auto_start="$5"
shift 5
local gateway_provider_ids=("$@")
local args=()
args=(docker run --rm)
append_common_container_args args
args+=(--entrypoint headroom "${HEADROOM_IMAGE}" wrap openclaw --prepare-only)
args+=(--proxy-port "${proxy_port}" --startup-timeout-ms "${startup_timeout_ms}")
if [[ -n "${existing_entry_json}" ]]; then
args+=(--existing-entry-json "${existing_entry_json}")
fi
if [[ -n "${python_path}" ]]; then
args+=(--python-path "${python_path}")
fi
if [[ "${no_auto_start}" -eq 1 ]]; then
args+=(--no-auto-start)
fi
local provider_id
for provider_id in "${gateway_provider_ids[@]}"; do
args+=(--gateway-provider-id "${provider_id}")
done
"${args[@]}"
}
prepare_openclaw_unwrap_entry_json() {
local existing_entry_json="$1"
local args=()
args=(docker run --rm)
append_common_container_args args
args+=(--entrypoint headroom "${HEADROOM_IMAGE}" unwrap openclaw --prepare-only)
if [[ -n "${existing_entry_json}" ]]; then
args+=(--existing-entry-json "${existing_entry_json}")
fi
"${args[@]}"
}
run_openclaw_checked() {
local action="$1"
shift
local output=""
if ! output="$("$@" 2>&1)"; then
output="${output//$'\r'/}"
die "${action} failed: ${output:-unknown error}"
fi
printf '%s' "${output//$'\r'/}"
}
run_openclaw_checked_in_dir() {
local action="$1"
local cwd="$2"
shift 2
local output=""
if ! output="$(cd "${cwd}" && "$@" 2>&1)"; then
output="${output//$'\r'/}"
die "${action} failed: ${output:-unknown error}"
fi
printf '%s' "${output//$'\r'/}"
}
resolve_openclaw_extensions_dir() {
local config_output
config_output="$(run_openclaw_checked "openclaw config file" openclaw config file)"
local config_path
config_path="$(printf '%s\n' "${config_output}" | tail -n 1)"
[[ -n "${config_path}" ]] || die "Unable to resolve OpenClaw config path."
printf '%s\n' "$(dirname "${config_path}")/extensions"
}
copy_openclaw_plugin_into_extensions() {
local plugin_dir="$1"
local dist_dir="${plugin_dir}/dist"
local hook_shim_dir="${plugin_dir}/hook-shim"
[[ -d "${dist_dir}" ]] || die "Plugin dist folder missing at ${dist_dir}. Build the plugin first."
[[ -d "${hook_shim_dir}" ]] || die "Plugin hook-shim folder missing at ${hook_shim_dir}. Build the plugin first."
local extensions_dir
extensions_dir="$(resolve_openclaw_extensions_dir)"
local target_dir="${extensions_dir}/headroom"
mkdir -p "${target_dir}"
rm -rf "${target_dir}/dist" "${target_dir}/hook-shim"
cp -R "${dist_dir}" "${target_dir}/dist"
cp -R "${hook_shim_dir}" "${target_dir}/hook-shim"
local filename
for filename in openclaw.plugin.json package.json README.md; do
if [[ -f "${plugin_dir}/${filename}" ]]; then
cp "${plugin_dir}/${filename}" "${target_dir}/${filename}"
fi
done
printf '%s\n' "${target_dir}"
}
install_openclaw_plugin() {
local plugin_path="$1"
local plugin_spec="$2"
local skip_build="$3"
local copy_mode="$4"
local verbose="$5"
local local_source_mode=0
if [[ -n "${plugin_path}" ]]; then
local_source_mode=1
[[ -d "${plugin_path}" ]] || die "Plugin path not found: ${plugin_path}."
[[ -f "${plugin_path}/package.json" ]] || die "Invalid plugin path (missing package.json): ${plugin_path}"
[[ -f "${plugin_path}/openclaw.plugin.json" ]] || die "Invalid plugin path (missing openclaw.plugin.json): ${plugin_path}"
fi
if [[ "${local_source_mode}" -eq 1 && "${skip_build}" -eq 0 ]]; then
require_cmd npm
info "Building OpenClaw plugin (npm install + npm run build)..."
run_openclaw_checked_in_dir "npm install" "${plugin_path}" npm install >/dev/null
run_openclaw_checked_in_dir "npm run build" "${plugin_path}" npm run build >/dev/null
fi
local install_output=""
local install_status=0
set +e
if [[ "${local_source_mode}" -eq 1 ]]; then
if [[ "${copy_mode}" -eq 1 ]]; then
install_output="$(openclaw plugins install --dangerously-force-unsafe-install "${plugin_path}" 2>&1)"
install_status=$?
else
install_output="$(cd "${plugin_path}" && openclaw plugins install --dangerously-force-unsafe-install --link . 2>&1)"
install_status=$?
fi
else
install_output="$(openclaw plugins install --dangerously-force-unsafe-install "${plugin_spec}" 2>&1)"
install_status=$?
fi
set -e
install_output="${install_output//$'\r'/}"
if [[ "${install_status}" -eq 0 ]]; then
if [[ "${verbose}" -eq 1 && -n "${install_output}" ]]; then
printf '%s\n' "${install_output}"
fi
return
fi
local lower_output="${install_output,,}"
if [[ "${lower_output}" == *"plugin already exists"* ]]; then
info "Plugin already installed; continuing with configuration/update steps."
return
fi
if [[ "${lower_output}" == *"also not a valid hook pack"* && "${local_source_mode}" -eq 1 && "${copy_mode}" -eq 0 ]]; then
info "OpenClaw linked-path install bug detected; applying extension-path fallback..."
local target_dir
target_dir="$(copy_openclaw_plugin_into_extensions "${plugin_path}")"
info "Fallback plugin copy completed: ${target_dir}"
return
fi
die "openclaw plugins install failed: ${install_output:-exit code ${install_status}}"
}
restart_or_start_openclaw_gateway() {
local output=""
if output="$(openclaw gateway restart 2>&1)"; then
OPENCLAW_GATEWAY_ACTION="restarted"
OPENCLAW_GATEWAY_OUTPUT="${output//$'\r'/}"
return
fi
OPENCLAW_GATEWAY_OUTPUT="$(run_openclaw_checked "openclaw gateway start" openclaw gateway start)"
OPENCLAW_GATEWAY_ACTION="started"
}
wrap_openclaw_host() {
local plugin_path plugin_spec skip_build copy_mode proxy_port startup_timeout_ms python_path
local no_auto_start no_restart verbose
local gateway_provider_ids=()
parse_openclaw_wrap_args \
plugin_path \
plugin_spec \
skip_build \
copy_mode \
proxy_port \
startup_timeout_ms \
gateway_provider_ids \
python_path \
no_auto_start \
no_restart \
verbose \
"$@"
require_cmd openclaw
local existing_entry_json=""
existing_entry_json="$(get_openclaw_existing_entry_json)"
local entry_json
entry_json="$(prepare_openclaw_entry_json "${existing_entry_json}" "${proxy_port}" "${startup_timeout_ms}" "${python_path}" "${no_auto_start}" "${gateway_provider_ids[@]}")"
printf '\n ╔═══════════════════════════════════════════════╗\n'
printf ' ║ HEADROOM WRAP: OPENCLAW ║\n'
printf ' ╚═══════════════════════════════════════════════╝\n\n'
if [[ -n "${plugin_path}" ]]; then
printf ' Plugin source: local (%s)\n' "${plugin_path}"
else
printf ' Plugin source: npm (%s)\n' "${plugin_spec}"
fi
printf ' Writing plugin configuration...\n'
run_openclaw_checked \
"openclaw config set plugins.entries.headroom" \
openclaw config set plugins.entries.headroom "${entry_json}" --strict-json >/dev/null
printf ' Installing OpenClaw plugin with required unsafe-install flag...\n'
install_openclaw_plugin "${plugin_path}" "${plugin_spec}" "${skip_build}" "${copy_mode}" "${verbose}"
run_openclaw_checked \
"openclaw config set plugins.slots.contextEngine" \
openclaw config set plugins.slots.contextEngine '"headroom"' --strict-json >/dev/null
run_openclaw_checked "openclaw config validate" openclaw config validate >/dev/null
if [[ "${no_restart}" -eq 1 ]]; then
printf ' Skipping gateway restart (--no-restart).\n'
printf ' Run `openclaw gateway restart` (or `openclaw gateway start`) to apply plugin changes.\n'
else
printf ' Applying plugin changes to OpenClaw gateway...\n'
restart_or_start_openclaw_gateway
printf ' Gateway %s.\n' "${OPENCLAW_GATEWAY_ACTION}"
if [[ "${verbose}" -eq 1 && -n "${OPENCLAW_GATEWAY_OUTPUT}" ]]; then
printf '%s\n' "${OPENCLAW_GATEWAY_OUTPUT}"
fi
fi
local inspect_output=""
inspect_output="$(run_openclaw_checked "openclaw plugins inspect headroom" openclaw plugins inspect headroom)"
if [[ "${verbose}" -eq 1 && -n "${inspect_output}" ]]; then
printf '%s\n' "${inspect_output}"
fi
printf '\n✓ OpenClaw is configured to use Headroom context compression.\n'
printf ' Plugin: headroom\n'
printf ' Slot: plugins.slots.contextEngine = headroom\n\n'
}
unwrap_openclaw_host() {
local no_restart verbose
parse_openclaw_unwrap_args no_restart verbose "$@"
require_cmd openclaw
local existing_entry_json=""
existing_entry_json="$(get_openclaw_existing_entry_json)"
local entry_json
entry_json="$(prepare_openclaw_unwrap_entry_json "${existing_entry_json}")"
printf '\n ╔═══════════════════════════════════════════════╗\n'
printf ' ║ HEADROOM UNWRAP: OPENCLAW ║\n'
printf ' ╚═══════════════════════════════════════════════╝\n\n'
printf ' Disabling Headroom plugin and removing engine mapping...\n'
run_openclaw_checked \
"openclaw config set plugins.entries.headroom" \
openclaw config set plugins.entries.headroom "${entry_json}" --strict-json >/dev/null
run_openclaw_checked \
"openclaw config set plugins.slots.contextEngine" \
openclaw config set plugins.slots.contextEngine '"legacy"' --strict-json >/dev/null
run_openclaw_checked "openclaw config validate" openclaw config validate >/dev/null
if [[ "${no_restart}" -eq 1 ]]; then
printf ' Skipping gateway restart (--no-restart).\n'
printf ' Run `openclaw gateway restart` (or `openclaw gateway start`) to apply unwrap changes.\n'
else
printf ' Applying unwrap changes to OpenClaw gateway...\n'
restart_or_start_openclaw_gateway
printf ' Gateway %s.\n' "${OPENCLAW_GATEWAY_ACTION}"
if [[ "${verbose}" -eq 1 && -n "${OPENCLAW_GATEWAY_OUTPUT}" ]]; then
printf '%s\n' "${OPENCLAW_GATEWAY_OUTPUT}"
fi
fi
if [[ "${verbose}" -eq 1 ]]; then
local inspect_output=""
inspect_output="$(run_openclaw_checked "openclaw plugins inspect headroom" openclaw plugins inspect headroom)"
if [[ -n "${inspect_output}" ]]; then
printf '%s\n' "${inspect_output}"
fi
fi
printf '\n✓ OpenClaw Headroom wrap removed.\n'
printf ' Plugin: headroom (installed, disabled)\n'
printf ' Slot: plugins.slots.contextEngine = legacy\n\n'
}
main() {
require_cmd docker
if (($# == 0)); then
run_headroom --help
return
fi
case "$1" in
wrap)
if (($# == 1)) || [[ "$2" == "--help" || "$2" == "-?" ]]; then
run_headroom wrap --help
return
fi
(($# >= 2)) || die "Usage: headroom wrap <claude|codex|aider|cursor|openclaw> [...]"
local tool="$2"
shift 2
if [[ "${tool}" == "openclaw" ]]; then
if contains_help_flag "$@"; then
run_headroom wrap openclaw "$@"
return
fi
wrap_openclaw_host "$@"
return
fi
if contains_help_flag "$@"; then
run_headroom wrap "${tool}" "$@"
return
fi
local known_args host_args port no_rtk no_proxy learn backend anyllm region
parse_wrap_args known_args host_args port no_rtk no_proxy learn backend anyllm region "$@"
local proxy_args=()
if [[ "${learn}" -eq 1 ]]; then
proxy_args+=(--learn)
fi
if [[ -n "${backend}" ]]; then
proxy_args+=(--backend "${backend}")
fi
if [[ -n "${anyllm}" ]]; then
proxy_args+=(--anyllm-provider "${anyllm}")
fi
if [[ -n "${region}" ]]; then
proxy_args+=(--region "${region}")
fi
case "${tool}" in
claude|codex|aider|cursor)
;;
*)
die "Unsupported wrap target: ${tool}"
;;
esac
local container_name=""
if [[ "${no_proxy}" -eq 0 ]]; then
container_name="$(start_proxy_container "${port}" "${proxy_args[@]}")"
fi
trap 'stop_proxy_container "${container_name}"' EXIT INT TERM
local prep_args=("${known_args[@]}")
if [[ "${no_proxy}" -eq 0 ]]; then
prep_args+=(--no-proxy)
fi
run_prepare_only "${tool}" "${prep_args[@]}"
case "${tool}" in
claude)
if [[ "${no_rtk}" -eq 0 ]]; then
run_claude_rtk_init
fi
ANTHROPIC_BASE_URL="http://127.0.0.1:${port}" run_host_tool claude "${host_args[@]}"
;;
codex)
OPENAI_BASE_URL="http://127.0.0.1:${port}/v1" run_host_tool codex "${host_args[@]}"
;;
aider)
OPENAI_API_BASE="http://127.0.0.1:${port}/v1" \
ANTHROPIC_BASE_URL="http://127.0.0.1:${port}" \
run_host_tool aider "${host_args[@]}"
;;
cursor)
cat <<EOF
Headroom proxy is running for Cursor.
OpenAI base URL: http://127.0.0.1:${port}/v1
Anthropic base URL: http://127.0.0.1:${port}
Press Ctrl+C to stop the proxy.
EOF
while true; do
sleep 1
done
;;
esac
;;
unwrap)
if (($# == 1)) || [[ "$2" == "--help" || "$2" == "-?" ]]; then
run_headroom unwrap --help
return
fi
if (($# >= 2)) && [[ "$2" == "openclaw" ]]; then
shift 2
if contains_help_flag "$@"; then
run_headroom unwrap openclaw "$@"
return
fi
unwrap_openclaw_host "$@"
return
fi
run_headroom "$@"
;;
proxy)
shift
local port=8787
local args=()
args=(proxy)
while (($#)); do
case "$1" in
--port|-p)
port="$2"
args+=("$1" "$2")
shift 2
;;
--port=*)
port="${1#*=}"
args+=("$1")
shift
;;
*)
args+=("$1")
shift
;;
esac
done
local run_args=()
run_args=(docker run --rm)
append_tty_args run_args
append_common_container_args run_args
run_args+=(-p "${port}:${port}")
run_args+=(--entrypoint headroom "${HEADROOM_IMAGE}" "${args[@]}")
"${run_args[@]}"
;;
*)
run_headroom "$@"
;;
esac
}
main "$@"
WRAPPER
chmod +x "${wrapper_path}"
}
main() {
require_cmd docker
docker version >/dev/null 2>&1 || die "Docker is installed but not available to the current user"
mkdir -p "${INSTALL_DIR}"
write_wrapper
append_path_block "${HOME}/.bashrc"
append_path_block "${HOME}/.zshrc"
append_path_block "${HOME}/.profile"
info "Pulling ${IMAGE_DEFAULT}"
docker pull "${IMAGE_DEFAULT}" >/dev/null
cat <<EOF
Headroom Docker-native install complete.
Installed wrapper:
${INSTALL_DIR}/headroom
Next steps:
1. Restart your shell or run: export PATH="${INSTALL_DIR}:\$PATH"
2. Try: headroom proxy
3. Docs: https://github.com/chopratejas/headroom/blob/main/docs/docker-install.md
EOF
}
main "$@"

View file

@ -0,0 +1,61 @@
"""Tests for top-level help and version aliases."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import patch
from click.testing import CliRunner
from headroom.cli.main import main
def test_root_help_short_alias() -> None:
runner = CliRunner()
result = runner.invoke(main, ["-?"])
assert result.exit_code == 0, result.output
assert "Usage:" in result.output
assert "--version" in result.output
def test_root_version_short_alias() -> None:
runner = CliRunner()
result = runner.invoke(main, ["-v"])
assert result.exit_code == 0, result.output
assert "version" in result.output.lower()
def test_group_help_short_alias() -> None:
runner = CliRunner()
result = runner.invoke(main, ["wrap", "-?"])
assert result.exit_code == 0, result.output
assert "Usage:" in result.output
assert "claude" in result.output
def test_wrap_subcommand_help_short_alias_beats_passthrough() -> None:
runner = CliRunner()
with patch("headroom.cli.wrap.shutil.which") as which_mock:
result = runner.invoke(main, ["wrap", "claude", "-?"])
assert result.exit_code == 0, result.output
assert "Usage:" in result.output
assert "Launch Claude Code through Headroom proxy." in result.output
which_mock.assert_not_called()
def test_subcommand_verbose_flag_still_works() -> None:
runner = CliRunner()
completed = SimpleNamespace(returncode=0)
with patch("headroom.cli.wrap.shutil.which", return_value="claude"):
with patch("headroom.cli.wrap._ensure_proxy", return_value=None):
with patch("headroom.cli.wrap._setup_rtk", return_value=None):
with patch("headroom.cli.wrap.subprocess.run", return_value=completed):
result = runner.invoke(main, ["wrap", "claude", "-v"])
assert result.exit_code == 0, result.output
assert "HEADROOM WRAP: CLAUDE" in result.output

View file

@ -0,0 +1,124 @@
"""Tests for Docker-bridge wrap preparation flows."""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import patch
from click.testing import CliRunner
from headroom.cli.main import main
def _set_test_home(monkeypatch, tmp_path: Path) -> None:
home = str(tmp_path)
monkeypatch.setenv("HOME", home)
monkeypatch.setenv("USERPROFILE", home)
def test_wrap_claude_prepare_only_skips_host_binary_lookup() -> None:
runner = CliRunner()
with patch("headroom.cli.wrap._prepare_wrap_rtk") as prepare_rtk:
with patch("headroom.cli.wrap.shutil.which") as which_mock:
result = runner.invoke(main, ["wrap", "claude", "--prepare-only"])
assert result.exit_code == 0, result.output
prepare_rtk.assert_called_once()
which_mock.assert_not_called()
def test_wrap_codex_prepare_only_updates_config(monkeypatch, tmp_path: Path) -> None:
_set_test_home(monkeypatch, tmp_path)
runner = CliRunner()
with patch("headroom.cli.wrap._ensure_rtk_binary", return_value=None):
result = runner.invoke(main, ["wrap", "codex", "--prepare-only", "--port", "8787"])
assert result.exit_code == 0, result.output
config_file = tmp_path / ".codex" / "config.toml"
assert config_file.exists()
assert 'model_provider = "headroom"' in config_file.read_text()
assert 'base_url = "http://127.0.0.1:8787/v1"' in config_file.read_text()
def test_wrap_aider_prepare_only_injects_conventions(monkeypatch, tmp_path: Path) -> None:
_set_test_home(monkeypatch, tmp_path)
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=str(tmp_path)):
with patch("headroom.cli.wrap._ensure_rtk_binary", return_value=Path("rtk")):
result = runner.invoke(main, ["wrap", "aider", "--prepare-only"])
assert result.exit_code == 0, result.output
conventions = Path("CONVENTIONS.md")
assert conventions.exists()
assert "headroom:rtk-instructions" in conventions.read_text()
def test_wrap_cursor_prepare_only_injects_cursorrules(monkeypatch, tmp_path: Path) -> None:
_set_test_home(monkeypatch, tmp_path)
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=str(tmp_path)):
with patch("headroom.cli.wrap._ensure_rtk_binary", return_value=Path("rtk")):
result = runner.invoke(main, ["wrap", "cursor", "--prepare-only"])
assert result.exit_code == 0, result.output
cursorrules = Path(".cursorrules")
assert cursorrules.exists()
assert "headroom:rtk-instructions" in cursorrules.read_text()
def test_wrap_openclaw_prepare_only_emits_config_without_python_default() -> None:
runner = CliRunner()
result = runner.invoke(
main,
[
"wrap",
"openclaw",
"--prepare-only",
"--gateway-provider-id",
"codex",
"--gateway-provider-id",
"anthropic",
],
)
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert payload["enabled"] is True
assert payload["config"]["proxyPort"] == 8787
assert payload["config"]["gatewayProviderIds"] == ["codex", "anthropic"]
assert "pythonPath" not in payload["config"]
def test_unwrap_openclaw_prepare_only_preserves_unmanaged_config() -> None:
runner = CliRunner()
existing_entry = json.dumps(
{
"enabled": True,
"config": {
"pythonPath": "C:\\Python312\\python.exe",
"proxyPort": 8787,
"customFlag": True,
},
}
)
result = runner.invoke(
main,
[
"unwrap",
"openclaw",
"--prepare-only",
"--existing-entry-json",
existing_entry,
],
)
assert result.exit_code == 0, result.output
payload = json.loads(result.output)
assert payload == {"enabled": False, "config": {"customFlag": True}}

View file

@ -389,6 +389,24 @@ def test_build_openclaw_plugin_entry_sets_and_clears_python_path() -> None:
assert without_python["config"]["customFlag"] is True
def test_build_openclaw_unwrap_entry_preserves_top_level_metadata() -> None:
entry = wrap_cli._build_openclaw_unwrap_entry(
{
"source": "headroom-ai/openclaw",
"enabled": True,
"config": {
"pythonPath": "C:\\Python312\\python.exe",
"proxyPort": 8787,
"customFlag": True,
},
}
)
assert entry["source"] == "headroom-ai/openclaw"
assert entry["enabled"] is False
assert entry["config"] == {"customFlag": True}
def test_wrap_openclaw_no_auto_start_does_not_default_python_path(
runner: CliRunner, plugin_dir: Path
) -> None:

View file

@ -26,6 +26,60 @@ def runner():
class TestCLIProxyEnvVars:
"""Test that the CLI proxy command reads API URL env vars."""
def test_headroom_host_from_env(self, runner):
"""HEADROOM_HOST env var should be passed to ProxyConfig."""
captured_config = {}
def mock_run_server(config):
captured_config["config"] = config
with patch("headroom.proxy.server.run_server", mock_run_server):
result = runner.invoke(
main,
["proxy"],
env={"HEADROOM_HOST": "0.0.0.0"},
catch_exceptions=False,
)
assert result.exit_code == 0, result.output
assert captured_config["config"].host == "0.0.0.0"
def test_headroom_port_from_env(self, runner):
"""HEADROOM_PORT env var should be passed to ProxyConfig."""
captured_config = {}
def mock_run_server(config):
captured_config["config"] = config
with patch("headroom.proxy.server.run_server", mock_run_server):
result = runner.invoke(
main,
["proxy"],
env={"HEADROOM_PORT": "9797"},
catch_exceptions=False,
)
assert result.exit_code == 0, result.output
assert captured_config["config"].port == 9797
def test_headroom_budget_from_env(self, runner):
"""HEADROOM_BUDGET env var should be passed to ProxyConfig."""
captured_config = {}
def mock_run_server(config):
captured_config["config"] = config
with patch("headroom.proxy.server.run_server", mock_run_server):
result = runner.invoke(
main,
["proxy"],
env={"HEADROOM_BUDGET": "100.5"},
catch_exceptions=False,
)
assert result.exit_code == 0, result.output
assert captured_config["config"].budget_limit_usd == 100.5
def test_openai_target_api_url_from_env(self, runner):
"""OPENAI_TARGET_API_URL env var should be passed to ProxyConfig."""
captured_config = {}

View file

@ -0,0 +1,58 @@
"""Tests for host-target rtk installation overrides."""
from __future__ import annotations
import io
import tarfile
from pathlib import Path
from unittest.mock import patch
from headroom.rtk import get_rtk_path, installer
def test_get_rtk_path_finds_windows_managed_binary(tmp_path: Path) -> None:
managed_dir = tmp_path / ".headroom" / "bin"
managed_dir.mkdir(parents=True)
managed_path = managed_dir / "rtk.exe"
managed_path.write_bytes(b"binary")
with patch("headroom.rtk.RTK_BIN_DIR", managed_dir):
with patch("headroom.rtk.RTK_BIN_PATH", managed_dir / "rtk"):
with patch("headroom.rtk.shutil.which", return_value=None):
assert get_rtk_path() == managed_path
def test_get_target_triple_uses_override(monkeypatch) -> None:
monkeypatch.setenv("HEADROOM_RTK_TARGET", "x86_64-pc-windows-msvc")
assert installer._get_target_triple() == "x86_64-pc-windows-msvc"
def test_download_rtk_skips_verify_for_non_native_target(monkeypatch, tmp_path: Path) -> None:
archive = io.BytesIO()
with tarfile.open(fileobj=archive, mode="w:gz") as tf:
info = tarfile.TarInfo(name="rtk")
payload = b"fake-binary"
info.size = len(payload)
tf.addfile(info, io.BytesIO(payload))
archive_bytes = archive.getvalue()
class _Response:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def read(self) -> bytes:
return archive_bytes
monkeypatch.setenv("HEADROOM_RTK_TARGET", "x86_64-apple-darwin")
with patch.object(installer, "RTK_BIN_DIR", tmp_path):
with patch.object(installer, "urlopen", return_value=_Response()):
with patch.object(installer.subprocess, "run") as subprocess_run:
installed_path = installer.download_rtk("v0.28.2")
assert installed_path == tmp_path / "rtk"
assert installed_path.exists()
subprocess_run.assert_not_called()