diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index a315e336e25b737eee259ae99983bc02b808df7a..fe8c697cb823f94313d1476f9937dd35d1e2ee2c 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -1,2327 +1,2327 @@ -"""Wrap CLI commands to run through Headroom proxy. - -Usage: - headroom wrap claude # Start proxy + rtk + claude - headroom wrap copilot -- --model ... # Start proxy + launch GitHub Copilot CLI - headroom wrap codex # Start proxy + OpenAI Codex CLI - headroom wrap aider # Start proxy + aider - headroom wrap cursor # Start proxy + print Cursor config instructions - headroom wrap openclaw # Install + configure OpenClaw plugin - headroom wrap claude --no-rtk # Without rtk hooks - headroom wrap claude --port 9999 # Custom proxy port - headroom wrap claude -- --model opus # Pass args to claude -""" - -from __future__ import annotations - -import io -import json -import os -import shutil -import signal -import socket -import subprocess -import sys -import time -from pathlib import Path -from typing import Any, cast - -# Fix Windows cp1252 encoding — box-drawing characters require UTF-8 -if sys.platform == "win32" and hasattr(sys.stdout, "buffer"): - if sys.stdout.encoding and sys.stdout.encoding.lower().replace("-", "") != "utf8": - sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") - sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace") - -import click - -from headroom.copilot_auth import DEFAULT_API_URL as COPILOT_API_URL -from headroom.copilot_auth import has_oauth_auth, resolve_client_bearer_token -from headroom.providers.aider import build_launch_env as _build_aider_launch_env -from headroom.providers.claude import proxy_base_url as _claude_proxy_base_url -from headroom.providers.codex import build_launch_env as _build_codex_launch_env -from headroom.providers.copilot import ( - build_launch_env as _build_copilot_launch_env, -) -from headroom.providers.copilot import ( - detect_running_proxy_backend as _copilot_detect_running_proxy_backend, -) -from headroom.providers.copilot import ( - model_configured as _copilot_model_configured_impl, -) -from headroom.providers.copilot import ( - provider_key_source as _copilot_provider_key_source, -) -from headroom.providers.copilot import ( - query_proxy_config as _copilot_query_proxy_config, -) -from headroom.providers.copilot import ( - resolve_provider_type as _copilot_resolve_provider_type, -) -from headroom.providers.copilot import ( - validate_configuration as _validate_copilot_configuration, -) -from headroom.providers.cursor import render_setup_lines as _render_cursor_setup_lines -from headroom.providers.openclaw import ( - build_plugin_entry as _build_openclaw_plugin_entry_impl, -) -from headroom.providers.openclaw import ( - build_unwrap_entry as _build_openclaw_unwrap_entry_impl, -) -from headroom.providers.openclaw import ( - decode_entry_json as _decode_openclaw_entry_json_impl, -) -from headroom.providers.openclaw import ( - normalize_gateway_provider_ids as _normalize_openclaw_gateway_provider_ids_impl, -) - -from .main import main - - -def _live_wrap_module() -> Any: - """Return the current live wrap module instance.""" - return cast(Any, sys.modules[__name__]) - - -def _print_telemetry_notice() -> None: - """Print a telemetry notice when anonymous telemetry is enabled. - - Respects the HEADROOM_TELEMETRY and HEADROOM_TELEMETRY_WARN feature flags. - Does nothing when telemetry or warnings are disabled. - """ - from headroom.telemetry.beacon import format_telemetry_notice - - notice = format_telemetry_notice(prefix=" ") - if notice: - click.echo(notice) - - -# Proxy health check (reused from evals/suite_runner.py pattern) - - -def _check_proxy(port: int) -> bool: - """Check if Headroom proxy is running on given port.""" - try: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.settimeout(1) - s.connect(("127.0.0.1", port)) - return True - except (TimeoutError, ConnectionRefusedError, OSError): - return False - - -def _get_log_path() -> Path: - """Get path for proxy log file.""" - from headroom import paths as _paths - - log_dir = _paths.log_dir() - log_dir.mkdir(parents=True, exist_ok=True) - return log_dir / "proxy.log" - - -def _start_proxy( - port: int, - *, - learn: bool = False, - memory: bool = False, - agent_type: str = "unknown", - code_graph: bool = False, - backend: str | None = None, - anyllm_provider: str | None = None, - region: str | None = None, - openai_api_url: str | None = None, -) -> subprocess.Popen: - """Start Headroom proxy as a background subprocess. - - Logs are written to ~/.headroom/logs/proxy.log to avoid pipe buffer - deadlocks (macOS pipe buffer is ~64KB — a busy proxy fills it quickly, - blocking the process). - """ - cmd = [sys.executable, "-m", "headroom.cli", "proxy", "--port", str(port)] - - # Forward HEADROOM_MODE env var so the proxy respects the user's mode choice - headroom_mode = os.environ.get("HEADROOM_MODE") - if headroom_mode: - cmd.extend(["--mode", headroom_mode]) - - # Forward --learn flag to proxy subprocess - if learn: - cmd.append("--learn") - - # Forward --memory flag to proxy subprocess - if memory: - cmd.append("--memory") - - # Forward --code-graph flag to proxy subprocess (live file watcher) - if code_graph: - cmd.append("--code-graph") - - # Forward backend configuration to proxy subprocess - _backend = backend or os.environ.get("HEADROOM_BACKEND") - if _backend: - cmd.extend(["--backend", _backend]) - - _anyllm = anyllm_provider or os.environ.get("HEADROOM_ANYLLM_PROVIDER") - if _anyllm: - cmd.extend(["--anyllm-provider", _anyllm]) - - _region = region or os.environ.get("HEADROOM_REGION") - if _region: - cmd.extend(["--region", _region]) - - if openai_api_url: - cmd.extend(["--openai-api-url", openai_api_url]) - - log_path = _get_log_path() - log_file = open(log_path, "a") # noqa: SIM115 - - # Ensure proxy subprocess uses UTF-8 (Windows defaults to cp1252) - proxy_env = os.environ.copy() - proxy_env["PYTHONIOENCODING"] = "utf-8" - - # Tell the proxy which agent is being wrapped (for traffic learning output) - if agent_type != "unknown": - proxy_env["HEADROOM_AGENT_TYPE"] = agent_type - proxy_env.setdefault("HEADROOM_STACK", f"wrap_{agent_type}") - - proc = subprocess.Popen( - cmd, - stdout=log_file, - stderr=log_file, - env=proxy_env, - ) - - # Wait for proxy to be ready (up to 45 seconds). - # ML components (Kompress, Magika, Tree-sitter) load synchronously before - # uvicorn binds the port. On slower machines this can take 20-30 seconds. - for _i in range(45): - time.sleep(1) - if _check_proxy(port): - click.echo(f" Logs: {log_path}") - return proc - # Check if process died - if proc.poll() is not None: - log_file.close() - # Read last few lines of log for error context - try: - tail = log_path.read_text()[-500:] - except Exception: - tail = "(no log output)" - raise RuntimeError(f"Proxy exited with code {proc.returncode}: {tail}") - - proc.kill() - log_file.close() - raise RuntimeError(f"Proxy failed to start on port {port} within 45 seconds") - - -def _setup_rtk(verbose: bool = False) -> Path | None: - """Ensure rtk is installed and hooks are registered.""" - from headroom.rtk import get_rtk_path - from headroom.rtk.installer import ensure_rtk, register_claude_hooks - - rtk_path = get_rtk_path() - - if rtk_path: - if verbose: - click.echo(f" rtk found at {rtk_path}") - else: - click.echo(" Downloading rtk (Rust Token Killer)...") - rtk_path = ensure_rtk() - if rtk_path: - click.echo(f" rtk installed at {rtk_path}") - else: - click.echo(" rtk download failed — continuing without it") - return None - - # Register hooks (idempotent) - if register_claude_hooks(rtk_path): - if verbose: - click.echo(" rtk hooks registered in Claude Code") - else: - click.echo(" rtk hook registration failed — continuing without it") - - return rtk_path - - -_CBM_MCP_SERVER_NAME = "codebase-memory-mcp" - - -def _register_cbm_mcp_server(cbm_bin: str) -> None: - """Register codebase-memory-mcp as an MCP server in Claude Code. - - Uses ``claude mcp add`` so the tools appear in ``/mcp`` automatically. - Idempotent — skips if already registered. - """ - claude_cli = shutil.which("claude") - if not claude_cli: - return - - # Check if already registered - check = subprocess.run( - [claude_cli, "mcp", "get", _CBM_MCP_SERVER_NAME], - capture_output=True, - text=True, - ) - if check.returncode == 0: - return # Already registered - - result = subprocess.run( - [claude_cli, "mcp", "add", _CBM_MCP_SERVER_NAME, "-s", "user", "--", cbm_bin], - capture_output=True, - text=True, - ) - if result.returncode == 0: - click.echo(f" Code graph: registered {_CBM_MCP_SERVER_NAME} MCP server") - else: - pass # Non-critical — tools won't appear in /mcp but graph still works - - -def _setup_code_graph(verbose: bool = False) -> bool: - """Ensure codebase-memory-mcp is installed, registered as MCP server, and project is indexed. - - codebase-memory-mcp builds a knowledge graph of the codebase using - tree-sitter, enabling the LLM to query code structure (call chains, - function definitions, impact analysis) instead of reading entire files. - - Steps: - 1. Download the binary if not already present. - 2. Register as an MCP server in Claude Code (``claude mcp add``). - 3. Index the current project (fast, idempotent). - - With Claude Code's MCP Tool Search, the 14 graph tools add ~200 tokens - overhead per request (not the full ~1,915) — they're lazy-loaded. - - Returns True if graph is ready, False if setup failed. - """ - from headroom.graph.installer import ensure_cbm, get_cbm_path - - cbm_path = get_cbm_path() - if not cbm_path: - click.echo(" Code graph: downloading codebase-memory-mcp...") - cbm_path = ensure_cbm() - if cbm_path: - click.echo(f" Code graph: installed at {cbm_path}") - else: - click.echo(" Code graph: download failed — skipping") - return False - - cbm_bin = str(cbm_path) - - # Register as MCP server so tools appear in /mcp - _register_cbm_mcp_server(cbm_bin) - - # Index current project (fast — ~1s for most repos, idempotent) - project_dir = str(Path.cwd()) - try: - result = subprocess.run( - [ - cbm_bin, - "cli", - "index_repository", - json.dumps({"repo_path": project_dir, "mode": "fast"}), - ], - capture_output=True, - text=True, - timeout=30, - ) - if result.returncode == 0: - # Parse node/edge counts from output - for line in result.stdout.splitlines(): - if '"nodes"' in line: - try: - # Parse the JSON response to extract node/edge counts - import re - - m_nodes = re.search(r'"nodes":(\d+)', line) - m_edges = re.search(r'"edges":(\d+)', line) - if m_nodes and m_edges: - nodes = int(m_nodes.group(1)) - edges = int(m_edges.group(1)) - click.echo( - f" Code graph: indexed ({nodes:,} symbols, " - f"{edges:,} relationships)" - ) - else: - click.echo(" Code graph: indexed") - except (ValueError, AttributeError): - click.echo(" Code graph: indexed") - return True - click.echo(" Code graph: indexed") - return True - else: - if verbose: - click.echo(f" Code graph: indexing failed ({result.stderr[:100]})") - return False - except subprocess.TimeoutExpired: - click.echo(" Code graph: indexing timed out (will complete in background)") - return False - except Exception as e: - if verbose: - click.echo(f" Code graph: setup failed ({e})") - return False - - -# rtk instructions for tools without hook support (Codex, Cursor, Aider). -# These get injected into AGENTS.md / .cursorrules so the LLM voluntarily -# uses rtk-prefixed commands. Kept concise to minimize instruction overhead. -RTK_INSTRUCTIONS_BLOCK = """\ - -# RTK (Rust Token Killer) - Token-Optimized Commands - -When running shell commands, **always prefix with `rtk`**. This reduces context -usage by 60-90% with zero behavior change. If rtk has no filter for a command, -it passes through unchanged — so it is always safe to use. - -## Key Commands -```bash -# Git (59-80% savings) -rtk git status rtk git diff rtk git log - -# Files & Search (60-75% savings) -rtk ls rtk read rtk grep -rtk find rtk diff - -# Test (90-99% savings) — shows failures only -rtk pytest tests/ rtk cargo test rtk test - -# Build & Lint (80-90% savings) — shows errors only -rtk tsc rtk lint rtk cargo build -rtk prettier --check rtk mypy rtk ruff check - -# Analysis (70-90% savings) -rtk err rtk log rtk json -rtk summary rtk deps rtk env - -# GitHub (26-87% savings) -rtk gh pr view rtk gh run list rtk gh issue list - -# Infrastructure (85% savings) -rtk docker ps rtk kubectl get rtk docker logs - -# Package managers (70-90% savings) -rtk pip list rtk pnpm install rtk npm run