diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 000000000..25e5b050e --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,30 @@ +{ + "name": "headroom-marketplace", + "owner": { + "name": "Headroom Contributors" + }, + "metadata": { + "description": "Headroom marketplace for Claude Code and GitHub Copilot CLI plugins.", + "version": "0.1.0" + }, + "plugins": [ + { + "name": "headroom", + "source": "./plugins/headroom-agent-hooks", + "description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.", + "version": "0.1.0", + "author": { + "name": "Headroom Contributors", + "url": "https://github.com/JerrettDavis/headroom" + }, + "homepage": "https://github.com/JerrettDavis/headroom", + "repository": "https://github.com/JerrettDavis/headroom", + "keywords": [ + "headroom", + "hooks", + "claude-code", + "copilot-cli" + ] + } + ] +} diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json new file mode 100644 index 000000000..25e5b050e --- /dev/null +++ b/.github/plugin/marketplace.json @@ -0,0 +1,30 @@ +{ + "name": "headroom-marketplace", + "owner": { + "name": "Headroom Contributors" + }, + "metadata": { + "description": "Headroom marketplace for Claude Code and GitHub Copilot CLI plugins.", + "version": "0.1.0" + }, + "plugins": [ + { + "name": "headroom", + "source": "./plugins/headroom-agent-hooks", + "description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.", + "version": "0.1.0", + "author": { + "name": "Headroom Contributors", + "url": "https://github.com/JerrettDavis/headroom" + }, + "homepage": "https://github.com/JerrettDavis/headroom", + "repository": "https://github.com/JerrettDavis/headroom", + "keywords": [ + "headroom", + "hooks", + "claude-code", + "copilot-cli" + ] + } + ] +} diff --git a/README.md b/README.md index bedb39c53..cac4f432e 100644 --- a/README.md +++ b/README.md @@ -1,266 +1,274 @@ -
- -# Headroom - -**Compress everything your AI agent reads. Same answers, fraction of the tokens.** - -[![CI](https://github.com/chopratejas/headroom/actions/workflows/ci.yml/badge.svg)](https://github.com/chopratejas/headroom/actions/workflows/ci.yml) -[![PyPI](https://img.shields.io/pypi/v/headroom-ai.svg)](https://pypi.org/project/headroom-ai/) -[![npm](https://img.shields.io/npm/v/headroom-ai.svg)](https://www.npmjs.com/package/headroom-ai) -[![Model: Kompress-base](https://img.shields.io/badge/model-Kompress--base-yellow.svg)](https://huggingface.co/chopratejas/kompress-base) -[![Tokens saved: 60B+](https://img.shields.io/badge/tokens%20saved-60B%2B-2ea44f)](https://headroomlabs.ai/dashboard) -[![License: Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) -[![Docs](https://img.shields.io/badge/docs-online-blue.svg)](https://headroom-docs.vercel.app/docs) - -Headroom in action - -
- ---- - -Every tool call, log line, DB read, RAG chunk, and file your agent injects into a prompt is mostly boilerplate. Headroom strips the noise and keeps the signal — **losslessly, locally, and without touching accuracy.** - -> **100 logs. One FATAL error buried at position 67. Both runs found it.** -> Baseline **10,144 tokens** → Headroom **1,260 tokens** — **87% fewer, identical answer.** -> `python examples/needle_in_haystack_test.py` - ---- - -## Quick start - -Works with Anthropic, OpenAI, Google, Bedrock, Vertex, Azure, OpenRouter, and 100+ models via LiteLLM. - -**Wrap your coding agent — one command:** - -```bash -pip install "headroom-ai[all]" - -headroom wrap claude # Claude Code -headroom wrap codex # Codex -headroom wrap cursor # Cursor -headroom wrap aider # Aider -headroom wrap copilot # GitHub Copilot CLI -``` - -**Drop it into your own code — Python or TypeScript:** - -```python -from headroom import compress - -result = compress(messages, model="claude-sonnet-4-5") -response = client.messages.create(model="claude-sonnet-4-5", messages=result.messages) -print(f"Saved {result.tokens_saved} tokens ({result.compression_ratio:.0%})") -``` - -```typescript -import { compress } from 'headroom-ai'; -const result = await compress(messages, { model: 'gpt-4o' }); -``` - -**Or run it as a proxy — zero code changes, any language:** - -```bash -headroom proxy --port 8787 -ANTHROPIC_BASE_URL=http://localhost:8787 your-app -OPENAI_BASE_URL=http://localhost:8787/v1 your-app -``` - ---- - -## Why Headroom - -- **Accuracy-preserving.** GSM8K **0.870 → 0.870** (±0.000). TruthfulQA **+0.030**. SQuAD v2 and BFCL both **97%** accuracy after compression. Validated on public OSS benchmarks you can rerun yourself. -- **Runs on your machine.** No cloud API, no data egress. Compression latency is milliseconds — faster end-to-end for Sonnet / Opus / GPT-4 class models than a hosted service round-trip. -- **[Kompress-base](https://huggingface.co/chopratejas/kompress-base) on HuggingFace.** Our open-source text compressor, fine-tuned on real agentic traces — tool outputs, logs, RAG chunks, code. Install with `pip install "headroom-ai[ml]"`. -- **Cross-agent memory and learning.** Claude Code saves a fact, Codex reads it back. `headroom learn` mines failed sessions and writes corrections straight to `CLAUDE.md` / `AGENTS.md` / `GEMINI.md` — reliability compounds over time. -- **Reversible (CCR).** Compression is not deletion. The model can always call `headroom_retrieve` to pull the original bytes. Nothing is thrown away. - -Bundles the [RTK](https://github.com/rtk-ai/rtk) binary for shell-output rewriting — full [attribution below](#compared-to). - ---- - -## How it fits - -``` - Your agent / app - (Claude Code, Cursor, Codex, LangChain, Agno, Strands, your own code…) - │ prompts · tool outputs · logs · RAG results · files - ▼ - ┌────────────────────────────────────────────────────┐ - │ Headroom (runs locally — your data stays here) │ - │ ─────────────────────────────────────────────── │ - │ CacheAligner → ContentRouter → CCR │ - │ ├─ SmartCrusher (JSON) │ - │ ├─ CodeCompressor (AST) │ - │ └─ Kompress-base (text, HF) │ - │ │ - │ Cross-agent memory · headroom learn · MCP │ - └────────────────────────────────────────────────────┘ - │ compressed prompt + retrieval tool - ▼ - LLM provider (Anthropic · OpenAI · Bedrock · …) -``` - -→ [Architecture](https://headroom-docs.vercel.app/docs/architecture) · [CCR reversible compression](https://headroom-docs.vercel.app/docs/ccr) · [Kompress-base model card](https://huggingface.co/chopratejas/kompress-base) - ---- - -## Proof - -**Savings on real agent workloads:** - -| Workload | Before | After | Savings | -|-------------------------------|-------:|-------:|--------:| -| Code search (100 results) | 17,765 | 1,408 | **92%** | -| SRE incident debugging | 65,694 | 5,118 | **92%** | -| GitHub issue triage | 54,174 | 14,761 | **73%** | -| Codebase exploration | 78,502 | 41,254 | **47%** | - -**Accuracy preserved on standard benchmarks:** - -| Benchmark | Category | N | Baseline | Headroom | Delta | -|------------|----------|----:|---------:|---------:|----------:| -| GSM8K | Math | 100 | 0.870 | 0.870 | **±0.000**| -| TruthfulQA | Factual | 100 | 0.530 | 0.560 | **+0.030**| -| SQuAD v2 | QA | 100 | — | **97%** | 19% compression | -| BFCL | Tools | 100 | — | **97%** | 32% compression | - -Reproduce: - -```bash -python -m headroom.evals suite --tier 1 -``` - -**Community, live:** - -
- - 60B+ tokens saved — community leaderboard - -

60B+ tokens saved by the community in the last 20 days — live leaderboard →

-
- -→ [Full benchmarks & methodology](https://headroom-docs.vercel.app/docs/benchmarks) - ---- - -## Built for coding agents - -| Agent | One-command wrap | Notes | -|--------------------|------------------------------------|------------------------------------------------------------------| -| **Claude Code** | `headroom wrap claude` | `--memory` for cross-agent memory, `--code-graph` for codebase intel | -| **Codex** | `headroom wrap codex --memory` | Shares the same memory store as Claude | -| **Cursor** | `headroom wrap cursor` | Prints Cursor config — paste once, done | -| **Aider** | `headroom wrap aider` | Starts proxy, launches Aider | -| **Copilot CLI** | `headroom wrap copilot` | Starts proxy, launches Copilot | -| **OpenClaw** | `headroom wrap openclaw` | Installs Headroom as ContextEngine plugin | - -MCP-native too — `headroom mcp install` exposes `headroom_compress`, `headroom_retrieve`, and `headroom_stats` to any MCP client. - -
- headroom learn in action -
- ---- - -## Integrations - -
-Drop Headroom into any stack - -| Your setup | Hook in with | -|-------------------------|------------------------------------------------------------------| -| Any Python app | `compress(messages, model=…)` | -| Any TypeScript app | `await compress(messages, { model })` | -| Anthropic / OpenAI SDK | `withHeadroom(new Anthropic())` · `withHeadroom(new OpenAI())` | -| Vercel AI SDK | `wrapLanguageModel({ model, middleware: headroomMiddleware() })` | -| LiteLLM | `litellm.callbacks = [HeadroomCallback()]` | -| LangChain | `HeadroomChatModel(your_llm)` | -| Agno | `HeadroomAgnoModel(your_model)` | -| Strands | [Strands guide](https://headroom-docs.vercel.app/docs/strands) | -| ASGI apps | `app.add_middleware(CompressionMiddleware)` | -| Multi-agent | `SharedContext().put / .get` | -| MCP clients | `headroom mcp install` | - -
- -
-What's inside - -- **SmartCrusher** — universal JSON: arrays of dicts, nested objects, mixed types. -- **CodeCompressor** — AST-aware for Python, JS, Go, Rust, Java, C++. -- **Kompress-base** — our HuggingFace model, trained on agentic traces. -- **Image compression** — 40–90% reduction via trained ML router. -- **CacheAligner** — stabilizes prefixes so Anthropic/OpenAI KV caches actually hit. -- **IntelligentContext** — score-based context fitting with learned importance. -- **CCR** — reversible compression; LLM retrieves originals on demand. -- **Cross-agent memory** — shared store, agent provenance, auto-dedup. -- **SharedContext** — compressed context passing across multi-agent workflows. -- **`headroom learn`** — plugin-based failure mining for Claude, Codex, Gemini. - -
- ---- - -## Install - -```bash -pip install "headroom-ai[all]" # Python, everything -npm install headroom-ai # TypeScript / Node -docker pull ghcr.io/chopratejas/headroom:latest -``` - -Granular extras: `[proxy]`, `[mcp]`, `[ml]` (Kompress-base), `[agno]`, `[langchain]`, `[evals]`. Requires **Python 3.10+**. - -→ [Installation guide](https://headroom-docs.vercel.app/docs/installation) — Docker tags, persistent service, PowerShell, devcontainers. - ---- - -## Documentation - -| Start here | Go deeper | -|-------------------------------------------------------------------------|------------------------------------------------------------------------| -| [Quickstart](https://headroom-docs.vercel.app/docs/quickstart) | [Architecture](https://headroom-docs.vercel.app/docs/architecture) | -| [Proxy](https://headroom-docs.vercel.app/docs/proxy) | [How compression works](https://headroom-docs.vercel.app/docs/how-compression-works) | -| [MCP tools](https://headroom-docs.vercel.app/docs/mcp) | [CCR — reversible compression](https://headroom-docs.vercel.app/docs/ccr) | -| [Memory](https://headroom-docs.vercel.app/docs/memory) | [Cache optimization](https://headroom-docs.vercel.app/docs/cache-optimization) | -| [Failure learning](https://headroom-docs.vercel.app/docs/failure-learning) | [Benchmarks](https://headroom-docs.vercel.app/docs/benchmarks) | -| [Configuration](https://headroom-docs.vercel.app/docs/configuration) | [Limitations](https://headroom-docs.vercel.app/docs/limitations) | - ---- - -## Compared to - -Headroom runs **locally**, covers **every** content type (not just CLI or text), works with every major framework, and is **reversible**. - -| | Scope | Deploy | Local | Reversible | -|----------------------------------|-------------------------------------------------|-------------------------------------|:-----:|:----------:| -| **Headroom** | All context — tools, RAG, logs, files, history | Proxy · library · middleware · MCP | Yes | Yes | -| [RTK](https://github.com/rtk-ai/rtk) | CLI command outputs | CLI wrapper | Yes | No | -| [Compresr](https://compresr.ai), [Token Co.](https://thetokencompany.ai) | Text sent to their API | Hosted API call | No | No | -| OpenAI Compaction | Conversation history | Provider-native | No | No | - -> **Attribution.** Headroom ships with the excellent [RTK](https://github.com/rtk-ai/rtk) binary for shell-output rewriting — `git show` → `git show --short`, noisy `ls` → scoped, chatty installers → summarized. Huge thanks to the RTK team; their tool is a first-class part of our stack, and Headroom compresses everything downstream of it. - ---- - -## Contributing - -```bash -git clone https://github.com/chopratejas/headroom.git && cd headroom -pip install -e ".[dev]" && pytest -``` - -Devcontainers in `.devcontainer/` (default + `memory-stack` with Qdrant & Neo4j). See [CONTRIBUTING.md](CONTRIBUTING.md). - ---- - -## Community - -- **[Live leaderboard](https://headroomlabs.ai/dashboard)** — 60B+ tokens saved and counting. -- **[Discord](https://discord.gg/yRmaUNpsPJ)** — questions, feedback, war stories. -- **[Kompress-base on HuggingFace](https://huggingface.co/chopratejas/kompress-base)** — the model behind our text compression. - -## License - -Apache 2.0 — see [LICENSE](LICENSE). +
+ +# Headroom + +**Compress everything your AI agent reads. Same answers, fraction of the tokens.** + +[![CI](https://github.com/chopratejas/headroom/actions/workflows/ci.yml/badge.svg)](https://github.com/chopratejas/headroom/actions/workflows/ci.yml) +[![PyPI](https://img.shields.io/pypi/v/headroom-ai.svg)](https://pypi.org/project/headroom-ai/) +[![npm](https://img.shields.io/npm/v/headroom-ai.svg)](https://www.npmjs.com/package/headroom-ai) +[![Model: Kompress-base](https://img.shields.io/badge/model-Kompress--base-yellow.svg)](https://huggingface.co/chopratejas/kompress-base) +[![Tokens saved: 60B+](https://img.shields.io/badge/tokens%20saved-60B%2B-2ea44f)](https://headroomlabs.ai/dashboard) +[![License: Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) +[![Docs](https://img.shields.io/badge/docs-online-blue.svg)](https://headroom-docs.vercel.app/docs) + +Headroom in action + +
+ +--- + +Every tool call, log line, DB read, RAG chunk, and file your agent injects into a prompt is mostly boilerplate. Headroom strips the noise and keeps the signal — **losslessly, locally, and without touching accuracy.** + +> **100 logs. One FATAL error buried at position 67. Both runs found it.** +> Baseline **10,144 tokens** → Headroom **1,260 tokens** — **87% fewer, identical answer.** +> `python examples/needle_in_haystack_test.py` + +--- + +## Quick start + +Works with Anthropic, OpenAI, Google, Bedrock, Vertex, Azure, OpenRouter, and 100+ models via LiteLLM. + +**Wrap your coding agent — one command:** + +```bash +pip install "headroom-ai[all]" + +headroom wrap claude # Claude Code +headroom wrap codex # Codex +headroom wrap cursor # Cursor +headroom wrap aider # Aider +headroom wrap copilot # GitHub Copilot CLI +``` + +**Prefer a one-time durable install instead of wrapping every launch:** + +```bash +headroom init -g # Detect installed user-scoped agents and wire them to Headroom +headroom init claude # Install repo-local Claude hooks for just this project +headroom init copilot -g # Install user-scoped Copilot hooks and provider routing +``` + +**Drop it into your own code — Python or TypeScript:** + +```python +from headroom import compress + +result = compress(messages, model="claude-sonnet-4-5") +response = client.messages.create(model="claude-sonnet-4-5", messages=result.messages) +print(f"Saved {result.tokens_saved} tokens ({result.compression_ratio:.0%})") +``` + +```typescript +import { compress } from 'headroom-ai'; +const result = await compress(messages, { model: 'gpt-4o' }); +``` + +**Or run it as a proxy — zero code changes, any language:** + +```bash +headroom proxy --port 8787 +ANTHROPIC_BASE_URL=http://localhost:8787 your-app +OPENAI_BASE_URL=http://localhost:8787/v1 your-app +``` + +--- + +## Why Headroom + +- **Accuracy-preserving.** GSM8K **0.870 → 0.870** (±0.000). TruthfulQA **+0.030**. SQuAD v2 and BFCL both **97%** accuracy after compression. Validated on public OSS benchmarks you can rerun yourself. +- **Runs on your machine.** No cloud API, no data egress. Compression latency is milliseconds — faster end-to-end for Sonnet / Opus / GPT-4 class models than a hosted service round-trip. +- **[Kompress-base](https://huggingface.co/chopratejas/kompress-base) on HuggingFace.** Our open-source text compressor, fine-tuned on real agentic traces — tool outputs, logs, RAG chunks, code. Install with `pip install "headroom-ai[ml]"`. +- **Cross-agent memory and learning.** Claude Code saves a fact, Codex reads it back. `headroom learn` mines failed sessions and writes corrections straight to `CLAUDE.md` / `AGENTS.md` / `GEMINI.md` — reliability compounds over time. +- **Reversible (CCR).** Compression is not deletion. The model can always call `headroom_retrieve` to pull the original bytes. Nothing is thrown away. + +Bundles the [RTK](https://github.com/rtk-ai/rtk) binary for shell-output rewriting — full [attribution below](#compared-to). + +--- + +## How it fits + +``` + Your agent / app + (Claude Code, Cursor, Codex, LangChain, Agno, Strands, your own code…) + │ prompts · tool outputs · logs · RAG results · files + ▼ + ┌────────────────────────────────────────────────────┐ + │ Headroom (runs locally — your data stays here) │ + │ ─────────────────────────────────────────────── │ + │ CacheAligner → ContentRouter → CCR │ + │ ├─ SmartCrusher (JSON) │ + │ ├─ CodeCompressor (AST) │ + │ └─ Kompress-base (text, HF) │ + │ │ + │ Cross-agent memory · headroom learn · MCP │ + └────────────────────────────────────────────────────┘ + │ compressed prompt + retrieval tool + ▼ + LLM provider (Anthropic · OpenAI · Bedrock · …) +``` + +→ [Architecture](https://headroom-docs.vercel.app/docs/architecture) · [CCR reversible compression](https://headroom-docs.vercel.app/docs/ccr) · [Kompress-base model card](https://huggingface.co/chopratejas/kompress-base) + +--- + +## Proof + +**Savings on real agent workloads:** + +| Workload | Before | After | Savings | +|-------------------------------|-------:|-------:|--------:| +| Code search (100 results) | 17,765 | 1,408 | **92%** | +| SRE incident debugging | 65,694 | 5,118 | **92%** | +| GitHub issue triage | 54,174 | 14,761 | **73%** | +| Codebase exploration | 78,502 | 41,254 | **47%** | + +**Accuracy preserved on standard benchmarks:** + +| Benchmark | Category | N | Baseline | Headroom | Delta | +|------------|----------|----:|---------:|---------:|----------:| +| GSM8K | Math | 100 | 0.870 | 0.870 | **±0.000**| +| TruthfulQA | Factual | 100 | 0.530 | 0.560 | **+0.030**| +| SQuAD v2 | QA | 100 | — | **97%** | 19% compression | +| BFCL | Tools | 100 | — | **97%** | 32% compression | + +Reproduce: + +```bash +python -m headroom.evals suite --tier 1 +``` + +**Community, live:** + +
+ + 60B+ tokens saved — community leaderboard + +

60B+ tokens saved by the community in the last 20 days — live leaderboard →

+
+ +→ [Full benchmarks & methodology](https://headroom-docs.vercel.app/docs/benchmarks) + +--- + +## Built for coding agents + +| Agent | Durable init / one-shot wrap | Notes | +|--------------------|------------------------------------|------------------------------------------------------------------| +| **Claude Code** | `headroom init claude -g` / `headroom wrap claude` | `init` installs user or repo-local hooks; `wrap` is still useful for ad hoc sessions | +| **Codex** | `headroom init codex -g` / `headroom wrap codex --memory` | `init` installs provider config plus lifecycle hooks where supported | +| **Cursor** | `headroom wrap cursor` | Prints Cursor config — durable init not available yet | +| **Aider** | `headroom wrap aider` | Starts proxy, launches Aider | +| **Copilot CLI** | `headroom init copilot -g` / `headroom wrap copilot` | `init` installs hooks and BYOK provider routing for the current user | +| **OpenClaw** | `headroom init openclaw -g` / `headroom wrap openclaw` | Installs Headroom as ContextEngine plugin | + +MCP-native too — `headroom mcp install` exposes `headroom_compress`, `headroom_retrieve`, and `headroom_stats` to any MCP client. + +
+ headroom learn in action +
+ +--- + +## Integrations + +
+Drop Headroom into any stack + +| Your setup | Hook in with | +|-------------------------|------------------------------------------------------------------| +| Any Python app | `compress(messages, model=…)` | +| Any TypeScript app | `await compress(messages, { model })` | +| Anthropic / OpenAI SDK | `withHeadroom(new Anthropic())` · `withHeadroom(new OpenAI())` | +| Vercel AI SDK | `wrapLanguageModel({ model, middleware: headroomMiddleware() })` | +| LiteLLM | `litellm.callbacks = [HeadroomCallback()]` | +| LangChain | `HeadroomChatModel(your_llm)` | +| Agno | `HeadroomAgnoModel(your_model)` | +| Strands | [Strands guide](https://headroom-docs.vercel.app/docs/strands) | +| ASGI apps | `app.add_middleware(CompressionMiddleware)` | +| Multi-agent | `SharedContext().put / .get` | +| MCP clients | `headroom mcp install` | + +
+ +
+What's inside + +- **SmartCrusher** — universal JSON: arrays of dicts, nested objects, mixed types. +- **CodeCompressor** — AST-aware for Python, JS, Go, Rust, Java, C++. +- **Kompress-base** — our HuggingFace model, trained on agentic traces. +- **Image compression** — 40–90% reduction via trained ML router. +- **CacheAligner** — stabilizes prefixes so Anthropic/OpenAI KV caches actually hit. +- **IntelligentContext** — score-based context fitting with learned importance. +- **CCR** — reversible compression; LLM retrieves originals on demand. +- **Cross-agent memory** — shared store, agent provenance, auto-dedup. +- **SharedContext** — compressed context passing across multi-agent workflows. +- **`headroom learn`** — plugin-based failure mining for Claude, Codex, Gemini. + +
+ +--- + +## Install + +```bash +pip install "headroom-ai[all]" # Python, everything +npm install headroom-ai # TypeScript / Node +docker pull ghcr.io/chopratejas/headroom:latest +``` + +Granular extras: `[proxy]`, `[mcp]`, `[ml]` (Kompress-base), `[agno]`, `[langchain]`, `[evals]`. Requires **Python 3.10+**. + +→ [Installation guide](https://headroom-docs.vercel.app/docs/installation) — Docker tags, persistent service, PowerShell, devcontainers. + +--- + +## Documentation + +| Start here | Go deeper | +|-------------------------------------------------------------------------|------------------------------------------------------------------------| +| [Quickstart](https://headroom-docs.vercel.app/docs/quickstart) | [Architecture](https://headroom-docs.vercel.app/docs/architecture) | +| [Proxy](https://headroom-docs.vercel.app/docs/proxy) | [How compression works](https://headroom-docs.vercel.app/docs/how-compression-works) | +| [MCP tools](https://headroom-docs.vercel.app/docs/mcp) | [CCR — reversible compression](https://headroom-docs.vercel.app/docs/ccr) | +| [Memory](https://headroom-docs.vercel.app/docs/memory) | [Cache optimization](https://headroom-docs.vercel.app/docs/cache-optimization) | +| [Failure learning](https://headroom-docs.vercel.app/docs/failure-learning) | [Benchmarks](https://headroom-docs.vercel.app/docs/benchmarks) | +| [Configuration](https://headroom-docs.vercel.app/docs/configuration) | [Limitations](https://headroom-docs.vercel.app/docs/limitations) | + +--- + +## Compared to + +Headroom runs **locally**, covers **every** content type (not just CLI or text), works with every major framework, and is **reversible**. + +| | Scope | Deploy | Local | Reversible | +|----------------------------------|-------------------------------------------------|-------------------------------------|:-----:|:----------:| +| **Headroom** | All context — tools, RAG, logs, files, history | Proxy · library · middleware · MCP | Yes | Yes | +| [RTK](https://github.com/rtk-ai/rtk) | CLI command outputs | CLI wrapper | Yes | No | +| [Compresr](https://compresr.ai), [Token Co.](https://thetokencompany.ai) | Text sent to their API | Hosted API call | No | No | +| OpenAI Compaction | Conversation history | Provider-native | No | No | + +> **Attribution.** Headroom ships with the excellent [RTK](https://github.com/rtk-ai/rtk) binary for shell-output rewriting — `git show` → `git show --short`, noisy `ls` → scoped, chatty installers → summarized. Huge thanks to the RTK team; their tool is a first-class part of our stack, and Headroom compresses everything downstream of it. + +--- + +## Contributing + +```bash +git clone https://github.com/chopratejas/headroom.git && cd headroom +pip install -e ".[dev]" && pytest +``` + +Devcontainers in `.devcontainer/` (default + `memory-stack` with Qdrant & Neo4j). See [CONTRIBUTING.md](CONTRIBUTING.md). + +--- + +## Community + +- **[Live leaderboard](https://headroomlabs.ai/dashboard)** — 60B+ tokens saved and counting. +- **[Discord](https://discord.gg/yRmaUNpsPJ)** — questions, feedback, war stories. +- **[Kompress-base on HuggingFace](https://huggingface.co/chopratejas/kompress-base)** — the model behind our text compression. + +## License + +Apache 2.0 — see [LICENSE](LICENSE). diff --git a/headroom/cli/init.py b/headroom/cli/init.py new file mode 100644 index 000000000..e5ea3ca29 --- /dev/null +++ b/headroom/cli/init.py @@ -0,0 +1,679 @@ +"""Durable agent initialization commands.""" + +from __future__ import annotations + +import json +import os +import shlex +import shutil +import subprocess +from hashlib import sha1 +from pathlib import Path +from typing import Any + +import click + +from headroom.install.models import ConfigScope, InstallPreset, RuntimeKind, SupervisorKind +from headroom.install.paths import claude_settings_path, codex_config_path, validate_profile_name +from headroom.install.planner import build_manifest +from headroom.install.providers import _apply_unix_env_scope, _apply_windows_env_scope +from headroom.install.runtime import ( + resolve_headroom_command, + start_detached_agent, + start_persistent_docker, + stop_runtime, + wait_ready, +) +from headroom.install.state import load_manifest, save_manifest +from headroom.install.supervisors import start_supervisor + +from .main import main + +_GLOBAL_PROFILE = "init-user" +_CLAUDE_HOOK_MARKER = "headroom-init-claude" +_COPILOT_HOOK_MARKER = "headroom-init-copilot" +_CODEX_HOOK_MARKER = "headroom-init-codex" +_CODEX_PROVIDER_MARKER_START = "# --- Headroom init provider ---" +_CODEX_PROVIDER_MARKER_END = "# --- end Headroom init provider ---" +_CODEX_FEATURE_MARKER_START = "# --- Headroom init features ---" +_CODEX_FEATURE_MARKER_END = "# --- end Headroom init features ---" +_SUPPORTED_TARGETS = ("claude", "copilot", "codex", "openclaw") +_LOCAL_TARGETS = {"claude", "codex"} +_GLOBAL_TARGETS = {"claude", "copilot", "codex", "openclaw"} + + +def _command_string(parts: list[str]) -> str: + if os.name == "nt": + return subprocess.list2cmdline(parts) + return shlex.join(parts) + + +def _hook_command(*parts: str) -> str: + return _command_string([*resolve_headroom_command(), "init", "hook", "ensure", *parts]) + + +def _powershell_matcher() -> str: + return "Bash|PowerShell" if os.name == "nt" else "Bash" + + +def _local_profile(cwd: Path | None = None) -> str: + root = (cwd or Path.cwd()).resolve() + slug = "".join(ch if ch.isalnum() or ch in "-._" else "-" for ch in root.name.lower()).strip( + "-" + ) + digest = sha1(str(root).encode("utf-8")).hexdigest()[:8] + return validate_profile_name(f"init-{slug or 'repo'}-{digest}") + + +def _runtime_profile(global_scope: bool, cwd: Path | None = None) -> str: + return _GLOBAL_PROFILE if global_scope else _local_profile(cwd) + + +def _copilot_config_path() -> Path: + return Path.home() / ".copilot" / "config.json" + + +def _codex_hooks_path(global_scope: bool) -> Path: + return (Path.home() if global_scope else Path.cwd()) / ".codex" / "hooks.json" + + +def _claude_scope_path(global_scope: bool) -> Path: + if global_scope: + return claude_settings_path() + return Path.cwd() / ".claude" / "settings.local.json" + + +def _codex_scope_path(global_scope: bool) -> Path: + if global_scope: + return codex_config_path() + return Path.cwd() / ".codex" / "config.toml" + + +def _json_file(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + content = path.read_text(encoding="utf-8").strip() + if not content: + return {} + payload = json.loads(content) + return payload if isinstance(payload, dict) else {} + + +def _write_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + + +def _ensure_claude_hooks(path: Path, profile: str, port: int) -> None: + payload = _json_file(path) + env_map = dict(payload.get("env") or {}) if isinstance(payload.get("env"), dict) else {} + env_map["ANTHROPIC_BASE_URL"] = f"http://127.0.0.1:{port}" + payload["env"] = env_map + + hooks = dict(payload.get("hooks") or {}) if isinstance(payload.get("hooks"), dict) else {} + command = _hook_command("--profile", profile) + for event, matcher in ( + ("SessionStart", "startup|resume"), + ("PreToolUse", _powershell_matcher()), + ): + entries = list(hooks.get(event) or []) if isinstance(hooks.get(event), list) else [] + retained: list[dict[str, Any]] = [] + for entry in entries: + if not isinstance(entry, dict): + retained.append(entry) + continue + hook_items = entry.get("hooks") + if not isinstance(hook_items, list): + retained.append(entry) + continue + has_headroom = any( + isinstance(item, dict) + and item.get("command") + and _CLAUDE_HOOK_MARKER in str(item.get("command")) + for item in hook_items + ) + if not has_headroom: + retained.append(entry) + retained.append( + { + "matcher": matcher, + "hooks": [ + { + "type": "command", + "command": f"{command} --marker {_CLAUDE_HOOK_MARKER}", + "timeout": 15, + } + ], + } + ) + hooks[event] = retained + payload["hooks"] = hooks + _write_json(path, payload) + + +def _ensure_copilot_hooks(path: Path, profile: str) -> None: + payload = _json_file(path) + hooks = dict(payload.get("hooks") or {}) if isinstance(payload.get("hooks"), dict) else {} + command = f"{_hook_command('--profile', profile)} --marker {_COPILOT_HOOK_MARKER}" + for event in ("SessionStart", "PreToolUse"): + entries = list(hooks.get(event) or []) if isinstance(hooks.get(event), list) else [] + retained = [ + entry + for entry in entries + if not ( + isinstance(entry, dict) and _COPILOT_HOOK_MARKER in str(entry.get("command", "")) + ) + ] + retained.append({"type": "command", "command": command, "cwd": ".", "timeout": 15}) + hooks[event] = retained + payload["hooks"] = hooks + _write_json(path, payload) + + +def _replace_marker_block(content: str, marker_start: str, marker_end: str, block: str) -> str: + if marker_start in content and marker_end in content: + start = content.index(marker_start) + end = content.index(marker_end) + len(marker_end) + content = content[:start].rstrip() + "\n\n" + content[end:].lstrip() + return (content.rstrip() + "\n\n" + block.strip() + "\n").lstrip() + + +def _ensure_codex_provider(path: Path, port: int) -> None: + block = ( + f"{_CODEX_PROVIDER_MARKER_START}\n" + 'model_provider = "headroom"\n\n' + "[model_providers.headroom]\n" + 'name = "Headroom init proxy"\n' + f'base_url = "http://127.0.0.1:{port}/v1"\n' + 'env_key = "OPENAI_API_KEY"\n' + "requires_openai_auth = true\n" + "supports_websockets = true\n" + f"{_CODEX_PROVIDER_MARKER_END}" + ) + content = path.read_text(encoding="utf-8") if path.exists() else "" + content = _replace_marker_block( + content, _CODEX_PROVIDER_MARKER_START, _CODEX_PROVIDER_MARKER_END, block + ) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def _ensure_codex_feature_flag(path: Path) -> None: + content = path.read_text(encoding="utf-8") if path.exists() else "" + if _CODEX_FEATURE_MARKER_START in content and _CODEX_FEATURE_MARKER_END in content: + block = f"{_CODEX_FEATURE_MARKER_START}\ncodex_hooks = true\n{_CODEX_FEATURE_MARKER_END}" + content = _replace_marker_block( + content, + _CODEX_FEATURE_MARKER_START, + _CODEX_FEATURE_MARKER_END, + block, + ) + elif "[features]" in content: + lines = content.splitlines() + inserted = False + for index, line in enumerate(lines): + if line.strip() != "[features]": + continue + section_end = index + 1 + while section_end < len(lines) and not ( + lines[section_end].startswith("[") and lines[section_end].endswith("]") + ): + if "codex_hooks" in lines[section_end]: + inserted = True + break + section_end += 1 + if not inserted: + lines[index + 1 : index + 1] = [ + _CODEX_FEATURE_MARKER_START, + "codex_hooks = true", + _CODEX_FEATURE_MARKER_END, + ] + inserted = True + break + content = "\n".join(lines).rstrip() + "\n" + if not inserted: + content = ( + content.rstrip() + + "\n\n[features]\n" + + _CODEX_FEATURE_MARKER_START + + "\n" + + "codex_hooks = true\n" + + _CODEX_FEATURE_MARKER_END + + "\n" + ) + else: + content = ( + content.rstrip() + + "\n\n[features]\n" + + _CODEX_FEATURE_MARKER_START + + "\n" + + "codex_hooks = true\n" + + _CODEX_FEATURE_MARKER_END + + "\n" + ).lstrip() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def _ensure_codex_hooks(path: Path, profile: str) -> None: + command = f"{_hook_command('--profile', profile)} --marker {_CODEX_HOOK_MARKER}" + payload = { + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume", + "hooks": [{"type": "command", "command": command, "timeout": 15}], + } + ], + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [{"type": "command", "command": command, "timeout": 15}], + } + ], + } + } + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + + +def _manifest_changed( + existing: Any, + *, + port: int, + backend: str, + anyllm_provider: str | None, + region: str | None, + memory: bool, +) -> bool: + return any( + [ + getattr(existing, "port", port) != port, + getattr(existing, "backend", backend) != backend, + getattr(existing, "anyllm_provider", anyllm_provider) != anyllm_provider, + getattr(existing, "region", region) != region, + getattr(existing, "memory_enabled", memory) != memory, + ] + ) + + +def _ensure_runtime_manifest( + *, + global_scope: bool, + targets: list[str], + port: int, + backend: str, + anyllm_provider: str | None, + region: str | None, + memory: bool, +) -> str: + profile = _runtime_profile(global_scope) + existing = load_manifest(profile) + merged_targets = sorted(set(existing.targets if existing else []).union(targets)) + manifest = build_manifest( + profile=profile, + preset=InstallPreset.PERSISTENT_TASK.value, + runtime_kind=RuntimeKind.PYTHON.value, + scope=ConfigScope.USER.value, + provider_mode="manual", + targets=merged_targets, + port=port, + backend=backend, + anyllm_provider=anyllm_provider, + region=region, + proxy_mode="token", + memory_enabled=memory, + telemetry_enabled=True, + image="ghcr.io/chopratejas/headroom:latest", + ) + manifest.supervisor_kind = SupervisorKind.NONE.value + manifest.artifacts = [] + manifest.mutations = existing.mutations if existing else [] + if existing is not None and _manifest_changed( + existing, + port=port, + backend=backend, + anyllm_provider=anyllm_provider, + region=region, + memory=memory, + ): + try: + stop_runtime(existing) + except Exception: + pass + save_manifest(manifest) + return profile + + +def _env_manifest(values: dict[str, str]) -> Any: + return build_manifest( + profile="init-env", + preset=InstallPreset.PERSISTENT_TASK.value, + runtime_kind=RuntimeKind.PYTHON.value, + scope=ConfigScope.USER.value, + provider_mode="manual", + targets=["copilot"], + port=8787, + backend="anthropic", + anyllm_provider=None, + region=None, + proxy_mode="token", + memory_enabled=False, + telemetry_enabled=True, + image="ghcr.io/chopratejas/headroom:latest", + ) + + +def _apply_user_env(values: dict[str, str]) -> None: + manifest = _env_manifest(values) + manifest.base_env = {} + manifest.tool_envs = {"copilot": values} + if os.name == "nt": + _apply_windows_env_scope(manifest) + else: + _apply_unix_env_scope(manifest) + + +def _resolve_copilot_env(port: int, backend: str) -> dict[str, str]: + if backend == "anthropic": + return { + "COPILOT_PROVIDER_TYPE": "anthropic", + "COPILOT_PROVIDER_BASE_URL": f"http://127.0.0.1:{port}", + } + return { + "COPILOT_PROVIDER_TYPE": "openai", + "COPILOT_PROVIDER_BASE_URL": f"http://127.0.0.1:{port}/v1", + "COPILOT_PROVIDER_WIRE_API": "completions", + } + + +def _marketplace_source() -> str: + override = os.environ.get("HEADROOM_MARKETPLACE_SOURCE") + if override: + return override + repo_root = Path(__file__).resolve().parents[2] + if (repo_root / ".claude-plugin" / "marketplace.json").exists(): + return str(repo_root) + return "JerrettDavis/headroom" + + +def _run_checked(command: list[str], *, action: str) -> None: + result = subprocess.run( + command, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + if result.returncode == 0: + return + detail = "\n".join(part for part in (result.stderr.strip(), result.stdout.strip()) if part) + if "already" in detail.lower() or "exists" in detail.lower(): + return + raise click.ClickException(f"{action} failed: {detail or result.returncode}") + + +def _install_claude_marketplace(scope: str) -> None: + claude_bin = shutil.which("claude") + if not claude_bin: + raise click.ClickException("'claude' not found in PATH. Install Claude Code first.") + source = _marketplace_source() + _run_checked( + [claude_bin, "plugin", "marketplace", "add", source], action="claude marketplace add" + ) + _run_checked( + [claude_bin, "plugin", "install", "headroom@headroom-marketplace", "--scope", scope], + action="claude plugin install", + ) + + +def _install_copilot_marketplace() -> None: + copilot_bin = shutil.which("copilot") + if not copilot_bin: + raise click.ClickException("'copilot' not found in PATH. Install GitHub Copilot CLI first.") + source = _marketplace_source() + _run_checked( + [copilot_bin, "plugin", "marketplace", "add", source], + action="copilot marketplace add", + ) + _run_checked( + [copilot_bin, "plugin", "install", "headroom@headroom-marketplace"], + action="copilot plugin install", + ) + + +def _ensure_profile_running(profile: str) -> None: + manifest = load_manifest(profile) + if manifest is None: + return + if wait_ready(manifest, timeout_seconds=1): + return + try: + if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value: + start_persistent_docker(manifest) + elif manifest.supervisor_kind == SupervisorKind.SERVICE.value: + start_supervisor(manifest) + else: + start_detached_agent(manifest.profile) + wait_ready(manifest, timeout_seconds=45) + except Exception: + return + + +def detect_init_targets(global_scope: bool) -> list[str]: + allowed = _GLOBAL_TARGETS if global_scope else _LOCAL_TARGETS + detected: list[str] = [] + for target in _SUPPORTED_TARGETS: + if target not in allowed: + continue + if shutil.which(target): + detected.append(target) + return detected + + +def _init_claude(*, global_scope: bool, profile: str, port: int) -> None: + _ensure_claude_hooks(_claude_scope_path(global_scope), profile, port) + _install_claude_marketplace("user" if global_scope else "local") + click.echo(f"Configured Claude Code ({'user' if global_scope else 'local'} scope).") + click.echo("Restart Claude Code to activate Headroom hooks and provider routing.") + + +def _init_copilot(*, global_scope: bool, profile: str, port: int, backend: str) -> None: + if not global_scope: + raise click.ClickException( + "Copilot durable init currently requires -g (current-user scope)." + ) + _ensure_copilot_hooks(_copilot_config_path(), profile) + _apply_user_env(_resolve_copilot_env(port, backend)) + _install_copilot_marketplace() + click.echo("Configured GitHub Copilot CLI (user scope).") + click.echo("Restart Copilot CLI to activate Headroom hooks and provider routing.") + + +def _init_codex(*, global_scope: bool, profile: str, port: int) -> None: + config_path = _codex_scope_path(global_scope) + _ensure_codex_provider(config_path, port) + _ensure_codex_feature_flag(config_path) + _ensure_codex_hooks(_codex_hooks_path(global_scope), profile) + click.echo(f"Configured Codex ({'user' if global_scope else 'local'} scope).") + if os.name == "nt": + click.echo( + "Codex hooks are currently disabled upstream on Windows; provider routing was still installed." + ) + click.echo("Restart Codex to activate Headroom configuration.") + + +def _init_openclaw(*, global_scope: bool, port: int) -> None: + if not global_scope: + raise click.ClickException( + "OpenClaw durable init currently requires -g (current-user scope)." + ) + command = [*resolve_headroom_command(), "wrap", "openclaw", "--proxy-port", str(port)] + result = subprocess.run(command) + if result.returncode != 0: + raise SystemExit(result.returncode) + + +def _run_init_targets( + *, + targets: list[str], + global_scope: bool, + port: int, + backend: str, + anyllm_provider: str | None, + region: str | None, + memory: bool, +) -> None: + runtime_targets = [target for target in targets if target != "openclaw"] + profile = _ensure_runtime_manifest( + global_scope=global_scope, + targets=runtime_targets, + port=port, + backend=backend, + anyllm_provider=anyllm_provider, + region=region, + memory=memory, + ) + for target in targets: + if target == "claude": + _init_claude(global_scope=global_scope, profile=profile, port=port) + elif target == "copilot": + _init_copilot(global_scope=global_scope, profile=profile, port=port, backend=backend) + elif target == "codex": + _init_codex(global_scope=global_scope, profile=profile, port=port) + elif target == "openclaw": + _init_openclaw(global_scope=global_scope, port=port) + + +@main.group(invoke_without_command=True) +@click.option("-g", "--global", "global_scope", is_flag=True, help="Install for the current user.") +@click.option("--port", default=8787, type=int, show_default=True, help="Headroom proxy port.") +@click.option("--backend", default="anthropic", show_default=True, help="Proxy backend.") +@click.option("--anyllm-provider", default=None, help="Provider for any-llm backends.") +@click.option("--region", default=None, help="Cloud region for Bedrock / Vertex style backends.") +@click.option("--memory", is_flag=True, help="Enable persistent memory in the proxy runtime.") +@click.pass_context +def init( + ctx: click.Context, + global_scope: bool, + port: int, + backend: str, + anyllm_provider: str | None, + region: str | None, + memory: bool, +) -> None: + """Install durable Headroom integrations for supported agents.""" + if ctx.invoked_subcommand is not None: + ctx.obj = { + "global_scope": global_scope, + "port": port, + "backend": backend, + "anyllm_provider": anyllm_provider, + "region": region, + "memory": memory, + } + return + + targets = detect_init_targets(global_scope) + if not targets: + scope_label = "user" if global_scope else "local" + raise click.ClickException( + f"No supported {scope_label} init targets were auto-detected. Specify one explicitly." + ) + _run_init_targets( + targets=targets, + global_scope=global_scope, + port=port, + backend=backend, + anyllm_provider=anyllm_provider, + region=region, + memory=memory, + ) + + +def _ctx_value(ctx: click.Context, key: str) -> Any: + return (ctx.obj or {}).get(key) + + +@init.command("claude") +@click.pass_context +def init_claude(ctx: click.Context) -> None: + """Install Claude Code durable hooks and provider routing.""" + _run_init_targets( + targets=["claude"], + global_scope=bool(_ctx_value(ctx, "global_scope")), + port=int(_ctx_value(ctx, "port") or 8787), + backend=str(_ctx_value(ctx, "backend") or "anthropic"), + anyllm_provider=_ctx_value(ctx, "anyllm_provider"), + region=_ctx_value(ctx, "region"), + memory=bool(_ctx_value(ctx, "memory")), + ) + + +@init.command("copilot") +@click.pass_context +def init_copilot(ctx: click.Context) -> None: + """Install GitHub Copilot CLI durable hooks and provider routing.""" + _run_init_targets( + targets=["copilot"], + global_scope=bool(_ctx_value(ctx, "global_scope")), + port=int(_ctx_value(ctx, "port") or 8787), + backend=str(_ctx_value(ctx, "backend") or "anthropic"), + anyllm_provider=_ctx_value(ctx, "anyllm_provider"), + region=_ctx_value(ctx, "region"), + memory=bool(_ctx_value(ctx, "memory")), + ) + + +@init.command("codex") +@click.pass_context +def init_codex(ctx: click.Context) -> None: + """Install Codex durable hooks and provider routing.""" + _run_init_targets( + targets=["codex"], + global_scope=bool(_ctx_value(ctx, "global_scope")), + port=int(_ctx_value(ctx, "port") or 8787), + backend=str(_ctx_value(ctx, "backend") or "anthropic"), + anyllm_provider=_ctx_value(ctx, "anyllm_provider"), + region=_ctx_value(ctx, "region"), + memory=bool(_ctx_value(ctx, "memory")), + ) + + +@init.command("openclaw") +@click.pass_context +def init_openclaw(ctx: click.Context) -> None: + """Install the durable OpenClaw Headroom plugin.""" + _run_init_targets( + targets=["openclaw"], + global_scope=bool(_ctx_value(ctx, "global_scope")), + port=int(_ctx_value(ctx, "port") or 8787), + backend=str(_ctx_value(ctx, "backend") or "anthropic"), + anyllm_provider=_ctx_value(ctx, "anyllm_provider"), + region=_ctx_value(ctx, "region"), + memory=bool(_ctx_value(ctx, "memory")), + ) + + +@init.group("hook", hidden=True) +def init_hook() -> None: + """Internal hook helpers.""" + + +@init_hook.command("ensure") +@click.option("--profile", default=None, help="Explicit deployment profile to ensure.") +@click.option("--marker", default=None, hidden=True) +def init_hook_ensure(profile: str | None, marker: str | None) -> None: + """Best-effort ensure used by installed agent hooks.""" + del marker + profiles: list[str] = [] + if profile: + profiles.append(profile) + else: + local_profile = _local_profile() + if load_manifest(local_profile) is not None: + profiles.append(local_profile) + elif load_manifest(_GLOBAL_PROFILE) is not None: + profiles.append(_GLOBAL_PROFILE) + for name in profiles: + _ensure_profile_running(name) diff --git a/headroom/cli/main.py b/headroom/cli/main.py index c0bfaabf4..3d8eef75b 100644 --- a/headroom/cli/main.py +++ b/headroom/cli/main.py @@ -1,78 +1,79 @@ -"""Main CLI entry point for Headroom.""" - -import click - -CLI_CONTEXT_SETTINGS = {"help_option_names": ["--help", "-?"]} - - -def get_version() -> str: - """Get the current version.""" - try: - from headroom._version import __version__ - - return __version__ - except ImportError: - return "unknown" - - -@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. - - Manage memories, run the optimization proxy, and analyze metrics. - - \b - Examples: - headroom proxy Start the optimization proxy - headroom memory list List stored memories - headroom memory stats Show memory statistics - """ - ctx.ensure_object(dict) - - -# Import subcommands - these register themselves with the main group -def _register_commands() -> None: - """Register all subcommand groups.""" - from . import ( - evals, # noqa: F401 - install, # noqa: F401 - learn, # noqa: F401 - mcp, # noqa: F401 - perf, # noqa: F401 - proxy, # noqa: F401 - tools, # noqa: F401 - wrap, # noqa: F401 - ) - - # Memory CLI requires numpy/hnswlib — optional - try: - from . import memory # noqa: F401 - except ImportError: - pass - - -_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() +"""Main CLI entry point for Headroom.""" + +import click + +CLI_CONTEXT_SETTINGS = {"help_option_names": ["--help", "-?"]} + + +def get_version() -> str: + """Get the current version.""" + try: + from headroom._version import __version__ + + return __version__ + except ImportError: + return "unknown" + + +@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. + + Manage memories, run the optimization proxy, and analyze metrics. + + \b + Examples: + headroom proxy Start the optimization proxy + headroom memory list List stored memories + headroom memory stats Show memory statistics + """ + ctx.ensure_object(dict) + + +# Import subcommands - these register themselves with the main group +def _register_commands() -> None: + """Register all subcommand groups.""" + from . import ( + evals, # noqa: F401 + init, # noqa: F401 + install, # noqa: F401 + learn, # noqa: F401 + mcp, # noqa: F401 + perf, # noqa: F401 + proxy, # noqa: F401 + tools, # noqa: F401 + wrap, # noqa: F401 + ) + + # Memory CLI requires numpy/hnswlib — optional + try: + from . import memory # noqa: F401 + except ImportError: + pass + + +_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() diff --git a/plugins/headroom-agent-hooks/.claude-plugin/plugin.json b/plugins/headroom-agent-hooks/.claude-plugin/plugin.json new file mode 100644 index 000000000..6d180897c --- /dev/null +++ b/plugins/headroom-agent-hooks/.claude-plugin/plugin.json @@ -0,0 +1,17 @@ +{ + "name": "headroom", + "version": "0.1.0", + "description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.", + "author": { + "name": "Headroom Contributors", + "url": "https://github.com/JerrettDavis/headroom" + }, + "homepage": "https://github.com/JerrettDavis/headroom", + "repository": "https://github.com/JerrettDavis/headroom", + "keywords": [ + "headroom", + "hooks", + "claude-code", + "copilot-cli" + ] +} diff --git a/plugins/headroom-agent-hooks/.github/plugin/plugin.json b/plugins/headroom-agent-hooks/.github/plugin/plugin.json new file mode 100644 index 000000000..143337063 --- /dev/null +++ b/plugins/headroom-agent-hooks/.github/plugin/plugin.json @@ -0,0 +1,18 @@ +{ + "name": "headroom", + "version": "0.1.0", + "description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.", + "author": { + "name": "Headroom Contributors", + "url": "https://github.com/JerrettDavis/headroom" + }, + "homepage": "https://github.com/JerrettDavis/headroom", + "repository": "https://github.com/JerrettDavis/headroom", + "keywords": [ + "headroom", + "hooks", + "claude-code", + "copilot-cli" + ], + "hooks": "./hooks" +} diff --git a/plugins/headroom-agent-hooks/README.md b/plugins/headroom-agent-hooks/README.md new file mode 100644 index 000000000..fc78f1d9e --- /dev/null +++ b/plugins/headroom-agent-hooks/README.md @@ -0,0 +1,11 @@ +# Headroom agent hooks + +This plugin exposes lightweight startup hooks for Claude Code and GitHub Copilot CLI. + +The hooks call: + +```bash +headroom init hook ensure +``` + +That hidden helper checks for a matching durable `headroom init` deployment and starts it if needed. diff --git a/plugins/headroom-agent-hooks/hooks/hooks.json b/plugins/headroom-agent-hooks/hooks/hooks.json new file mode 100644 index 000000000..bf14a3132 --- /dev/null +++ b/plugins/headroom-agent-hooks/hooks/hooks.json @@ -0,0 +1,29 @@ +{ + "description": "Headroom plugin hooks — ensure the local Headroom runtime is available for initialized agents.", + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume", + "hooks": [ + { + "type": "command", + "command": "headroom init hook ensure", + "timeout": 15 + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash|PowerShell", + "hooks": [ + { + "type": "command", + "command": "headroom init hook ensure", + "timeout": 15 + } + ] + } + ] + } +} diff --git a/tests/test_cli/test_init_cli.py b/tests/test_cli/test_init_cli.py new file mode 100644 index 000000000..44af1c5dc --- /dev/null +++ b/tests/test_cli/test_init_cli.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +import importlib +import json +import sys +import types +from pathlib import Path + +import click +from click.testing import CliRunner + + +def _load_init_module(monkeypatch): + monkeypatch.delitem(sys.modules, "headroom.cli.init", raising=False) + monkeypatch.delitem(sys.modules, "headroom.cli.main", raising=False) + fake_main_module = types.ModuleType("headroom.cli.main") + + @click.group() + def fake_main() -> None: + pass + + fake_main_module.main = fake_main + monkeypatch.setitem(sys.modules, "headroom.cli.main", fake_main_module) + importlib.invalidate_caches() + init_cli = importlib.import_module("headroom.cli.init") + monkeypatch.delitem(sys.modules, "headroom.cli.init", raising=False) + return init_cli, fake_main + + +def test_init_auto_detects_targets(monkeypatch) -> None: + init_cli, fake_main = _load_init_module(monkeypatch) + runner = CliRunner() + captured: dict[str, object] = {} + + monkeypatch.setattr(init_cli, "detect_init_targets", lambda global_scope: ["claude", "codex"]) + monkeypatch.setattr(init_cli, "_run_init_targets", lambda **kwargs: captured.update(kwargs)) + + result = runner.invoke(fake_main, ["init", "-g"]) + + assert result.exit_code == 0, result.output + assert captured["targets"] == ["claude", "codex"] + assert captured["global_scope"] is True + + +def test_init_fails_when_auto_detection_empty(monkeypatch) -> None: + init_cli, fake_main = _load_init_module(monkeypatch) + runner = CliRunner() + monkeypatch.setattr(init_cli, "detect_init_targets", lambda global_scope: []) + + result = runner.invoke(fake_main, ["init"]) + + assert result.exit_code != 0 + assert "auto-detected" in result.output + + +def test_init_copilot_requires_global(monkeypatch) -> None: + init_cli, fake_main = _load_init_module(monkeypatch) + runner = CliRunner() + monkeypatch.setattr(init_cli, "_ensure_runtime_manifest", lambda **kwargs: "init-local-test") + + result = runner.invoke(fake_main, ["init", "copilot"]) + + assert result.exit_code != 0 + assert "requires -g" in result.output + + +def test_init_claude_local_writes_settings_and_installs_marketplace( + monkeypatch, tmp_path: Path +) -> None: + init_cli, fake_main = _load_init_module(monkeypatch) + runner = CliRunner() + monkeypatch.chdir(tmp_path) + marketplace_calls: list[str] = [] + monkeypatch.setattr(init_cli, "_ensure_runtime_manifest", lambda **kwargs: "init-local-demo") + monkeypatch.setattr( + init_cli, + "_install_claude_marketplace", + lambda scope: marketplace_calls.append(scope), + ) + + result = runner.invoke(fake_main, ["init", "claude"]) + + assert result.exit_code == 0, result.output + settings_path = tmp_path / ".claude" / "settings.local.json" + payload = json.loads(settings_path.read_text(encoding="utf-8")) + assert payload["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8787" + assert marketplace_calls == ["local"] + assert any( + "--profile init-local-demo" in hook["command"] and "init hook ensure" in hook["command"] + for entry in payload["hooks"]["SessionStart"] + for hook in entry["hooks"] + ) + + +def test_init_codex_merges_feature_flag_into_existing_table(monkeypatch, tmp_path: Path) -> None: + init_cli, _ = _load_init_module(monkeypatch) + monkeypatch.chdir(tmp_path) + config_path = tmp_path / ".codex" / "config.toml" + config_path.parent.mkdir(parents=True) + config_path.write_text("[features]\nshell_tool = true\n", encoding="utf-8") + + init_cli._init_codex(global_scope=False, profile="init-local-demo", port=9000) + + content = config_path.read_text(encoding="utf-8") + assert 'base_url = "http://127.0.0.1:9000/v1"' in content + assert content.count("[features]") == 1 + assert "codex_hooks = true" in content + hooks = json.loads((tmp_path / ".codex" / "hooks.json").read_text(encoding="utf-8")) + assert "--profile init-local-demo" in hooks["hooks"]["SessionStart"][0]["hooks"][0]["command"] + assert "init hook ensure" in hooks["hooks"]["SessionStart"][0]["hooks"][0]["command"] + + +def test_init_claude_uses_custom_port(monkeypatch, tmp_path: Path) -> None: + init_cli, _ = _load_init_module(monkeypatch) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(init_cli, "_install_claude_marketplace", lambda scope: None) + + init_cli._init_claude(global_scope=False, profile="init-local-demo", port=9011) + + payload = json.loads((tmp_path / ".claude" / "settings.local.json").read_text(encoding="utf-8")) + assert payload["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:9011" + + +def test_init_copilot_global_writes_hooks_and_env(monkeypatch, tmp_path: Path) -> None: + init_cli, _ = _load_init_module(monkeypatch) + captured_env: dict[str, str] = {} + monkeypatch.setattr(init_cli, "_copilot_config_path", lambda: tmp_path / "copilot-config.json") + monkeypatch.setattr(init_cli, "_apply_user_env", lambda values: captured_env.update(values)) + monkeypatch.setattr(init_cli, "_install_copilot_marketplace", lambda: None) + + init_cli._init_copilot(global_scope=True, profile="init-user", port=9005, backend="openai") + + payload = json.loads((tmp_path / "copilot-config.json").read_text(encoding="utf-8")) + assert "SessionStart" in payload["hooks"] + assert "PreToolUse" in payload["hooks"] + assert "--profile init-user" in payload["hooks"]["SessionStart"][0]["command"] + assert captured_env == { + "COPILOT_PROVIDER_TYPE": "openai", + "COPILOT_PROVIDER_BASE_URL": "http://127.0.0.1:9005/v1", + "COPILOT_PROVIDER_WIRE_API": "completions", + } + + +def test_init_hook_ensure_prefers_local_profile(monkeypatch) -> None: + init_cli, fake_main = _load_init_module(monkeypatch) + ensured: list[str] = [] + + def fake_load(profile: str): + return object() if profile == "init-repo-12345678" else None + + monkeypatch.setattr(init_cli, "_local_profile", lambda cwd=None: "init-repo-12345678") + monkeypatch.setattr(init_cli, "load_manifest", fake_load) + monkeypatch.setattr( + init_cli, "_ensure_profile_running", lambda profile: ensured.append(profile) + ) + + runner = CliRunner() + result = runner.invoke(fake_main, ["init", "hook", "ensure"]) + + assert result.exit_code == 0, result.output + assert ensured == ["init-repo-12345678"] + + +def test_init_openclaw_requires_global(monkeypatch) -> None: + _, fake_main = _load_init_module(monkeypatch) + runner = CliRunner() + + result = runner.invoke(fake_main, ["init", "openclaw"]) + + assert result.exit_code != 0 + assert "requires -g" in result.output + + +def test_init_openclaw_delegates_to_wrap(monkeypatch) -> None: + init_cli, _ = _load_init_module(monkeypatch) + calls: list[list[str]] = [] + + class _Result: + returncode = 0 + + monkeypatch.setattr(init_cli, "resolve_headroom_command", lambda: ["headroom"]) + monkeypatch.setattr( + init_cli.subprocess, + "run", + lambda cmd: calls.append(cmd) or _Result(), + ) + + init_cli._init_openclaw(global_scope=True, port=9999) + + assert calls == [["headroom", "wrap", "openclaw", "--proxy-port", "9999"]] + + +def test_detect_init_targets_respects_scope(monkeypatch) -> None: + init_cli, _ = _load_init_module(monkeypatch) + monkeypatch.setattr( + init_cli.shutil, + "which", + lambda name: name if name in {"claude", "copilot", "codex", "openclaw"} else None, + ) + + assert init_cli.detect_init_targets(False) == ["claude", "codex"] + assert init_cli.detect_init_targets(True) == ["claude", "copilot", "codex", "openclaw"] diff --git a/tests/test_plugin_manifests.py b/tests/test_plugin_manifests.py new file mode 100644 index 000000000..a3cd7b083 --- /dev/null +++ b/tests/test_plugin_manifests.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import json +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _load_json(relative_path: str) -> object: + return json.loads((REPO_ROOT / relative_path).read_text(encoding="utf-8")) + + +def test_marketplace_manifests_match() -> None: + assert _load_json(".claude-plugin/marketplace.json") == _load_json( + ".github/plugin/marketplace.json" + ) + + +def test_plugin_manifests_share_core_metadata() -> None: + claude = _load_json("plugins/headroom-agent-hooks/.claude-plugin/plugin.json") + copilot = _load_json("plugins/headroom-agent-hooks/.github/plugin/plugin.json") + assert isinstance(claude, dict) + assert isinstance(copilot, dict) + for key in ("name", "version", "description", "author", "homepage", "repository", "keywords"): + assert claude[key] == copilot[key] + assert "hooks" not in claude + assert copilot["hooks"] == "./hooks" + + +def test_marketplace_entry_points_to_plugin_root() -> None: + marketplace = _load_json(".claude-plugin/marketplace.json") + assert isinstance(marketplace, dict) + plugins = marketplace["plugins"] + assert isinstance(plugins, list) + plugin = plugins[0] + assert plugin["name"] == "headroom" + plugin_root = (REPO_ROOT / plugin["source"]).resolve() + assert plugin_root.is_dir() + assert (plugin_root / ".claude-plugin" / "plugin.json").is_file() + assert (plugin_root / "hooks" / "hooks.json").is_file()