mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
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:
commit
9f124c99ff
23 changed files with 3538 additions and 74 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -1,5 +1,9 @@
|
|||
# Private scripts (contain credentials)
|
||||
scripts/
|
||||
!scripts/
|
||||
scripts/*
|
||||
!scripts/install.sh
|
||||
!scripts/install.ps1
|
||||
|
||||
# Swift SDK (separate repo)
|
||||
swift/
|
||||
|
|
|
|||
14
README.md
14
README.md
|
|
@ -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
|
||||
|
|
|
|||
33
docker/docker-compose.native.yml
Normal file
33
docker/docker-compose.native.yml
Normal 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
711
docs/cli.md
Normal 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
130
docs/docker-install.md
Normal 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.
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
1089
scripts/install.ps1
Normal file
File diff suppressed because it is too large
Load diff
977
scripts/install.sh
Normal file
977
scripts/install.sh
Normal 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 "$@"
|
||||
61
tests/test_cli/test_main_help_version.py
Normal file
61
tests/test_cli/test_main_help_version.py
Normal 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
|
||||
124
tests/test_cli/test_wrap_bridge.py
Normal file
124
tests/test_cli/test_wrap_bridge.py
Normal 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}}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
|
|
|
|||
58
tests/test_rtk_installer.py
Normal file
58
tests/test_rtk_installer.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue