diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000000000000000000000000000000000000..b9450e26a9cd00308f54359da6dd033babc254a6 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,10 @@ +# Revisions listed here are skipped by `git blame --ignore-revs-file` +# and by GitHub's blame UI. Use for mechanical, repo-wide changes that +# touch every line of a file but don't change semantics (formatter runs, +# line-ending normalization, bulk renames applied by codemod, etc.). +# +# Configure your local git to auto-pick this up: +# git config blame.ignoreRevsFile .git-blame-ignore-revs + +# chore: renormalize line endings to LF +efd2ac1ca4d88d8f5990259c98b673c603902896 diff --git a/.gitattributes b/.gitattributes index dfdb8b771ce07609491fa2e83698969fd917a135..61d299b53fcbdcfa5697e0f87993ddda488c1a18 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ +*.py text eol=lf *.sh text eol=lf diff --git a/.github/actions/headroom-e2e-setup/action.yml b/.github/actions/headroom-e2e-setup/action.yml new file mode 100644 index 0000000000000000000000000000000000000000..0651b96993cf4f8758589c559a8618f102724209 --- /dev/null +++ b/.github/actions/headroom-e2e-setup/action.yml @@ -0,0 +1,68 @@ +name: Headroom e2e setup +description: >- + Checkout-agnostic setup shared by native e2e workflows (init, install, wrap). + Installs Python, installs headroom in editable mode, and (optionally) drops + a noop shim onto PATH so ``headroom init -g `` can detect a tool + that isn't actually installed on the runner. +inputs: + python-version: + description: Python version to install + required: false + default: "3.11" + shim-target: + description: >- + Name of the shim to drop on PATH (e.g. ``claude``, ``codex``). Leave + empty to skip shim creation. + required: false + default: "" +outputs: + shim-dir: + description: Absolute path to the directory containing the dropped shim + value: ${{ steps.shim.outputs.shim-dir }} +runs: + using: composite + steps: + - name: Set up Python ${{ inputs.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ inputs.python-version }} + + - name: Install headroom (editable, with proxy extras) + shell: bash + run: | + python -m pip install --upgrade pip + # ``headroom/cli/__init__.py`` eagerly imports ``proxy.server`` (via + # ``cli/proxy.py``), which requires ``fastapi`` even for ``init``. + # Install with the ``[proxy]`` extras to match the Docker e2e image. + pip install -e ".[proxy]" + + - name: Drop shim (POSIX) + if: ${{ inputs.shim-target != '' && runner.os != 'Windows' }} + id: shim-posix + shell: bash + run: | + shim_dir="${RUNNER_TEMP}/headroom-e2e-shims" + bash e2e/_lib/make_shim.sh "${{ inputs.shim-target }}" "$shim_dir" + echo "$shim_dir" >> "$GITHUB_PATH" + echo "shim-dir=$shim_dir" >> "$GITHUB_OUTPUT" + + - name: Drop shim (Windows) + if: ${{ inputs.shim-target != '' && runner.os == 'Windows' }} + id: shim-windows + shell: pwsh + run: | + $shimDir = Join-Path $env:RUNNER_TEMP "headroom-e2e-shims" + & pwsh -File e2e/_lib/make_shim.ps1 -Name "${{ inputs.shim-target }}" -Dir $shimDir + Add-Content -Path $env:GITHUB_PATH -Value $shimDir + "shim-dir=$shimDir" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + + - name: Export shim dir to job output + if: ${{ inputs.shim-target != '' }} + id: shim + shell: bash + run: | + if [ "${{ runner.os }}" = "Windows" ]; then + echo "shim-dir=${{ steps.shim-windows.outputs.shim-dir }}" >> "$GITHUB_OUTPUT" + else + echo "shim-dir=${{ steps.shim-posix.outputs.shim-dir }}" >> "$GITHUB_OUTPUT" + fi diff --git a/.github/workflows/init-native-e2e.yml b/.github/workflows/init-native-e2e.yml new file mode 100644 index 0000000000000000000000000000000000000000..cc9a4cdcbcf9c79ae9364f432d1b9bcd05a60a54 --- /dev/null +++ b/.github/workflows/init-native-e2e.yml @@ -0,0 +1,132 @@ +name: Init Native E2E + +# Cross-platform (linux / macos / windows) smoke tests for the per-subcommand +# ``headroom init -g `` flows. Each matrix cell drops a noop shim for +# the target agent onto PATH and asserts ``headroom init -g `` +# succeeds, writes the expected settings file, and (for claude/codex) places +# hooks in the right place. +# +# Deliberately scoped to pull_request + push-to-main + workflow_dispatch to +# avoid bloating CI minutes on every push to every feature branch. The Docker +# init-e2e.yml still runs on every PR and provides the deeper functional +# coverage; this workflow exists to catch platform-specific bugs (Windows +# path separators, macos keychain prompts, PowerShell-vs-bash hook matchers) +# that the single-platform Docker suite can miss. +# +# Extending to other commands (``headroom install``, ``headroom wrap``) is +# expected to be a near-copy of this file. The shared composite action at +# ``.github/actions/headroom-e2e-setup`` absorbs the Python + shim setup so +# each per-command workflow only supplies its matrix and assertion steps. + +on: + pull_request: + branches: [main] + paths: + - "headroom/cli/init.py" + - "headroom/install/**" + - "e2e/_lib/**" + - "e2e/init/**" + - ".github/actions/headroom-e2e-setup/**" + - ".github/workflows/init-native-e2e.yml" + push: + branches: [main] + workflow_dispatch: + +jobs: + init-native: + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + target: [claude, codex, copilot, openclaw] + exclude: + # openclaw delegates to ``headroom wrap openclaw`` which needs a + # running OpenClaw CLI; it can't be shimmed cheaply, so it's + # covered by the bundled Docker e2e instead. + - target: openclaw + + steps: + - uses: actions/checkout@v4 + + - name: Setup (shim=${{ matrix.target }}) + uses: ./.github/actions/headroom-e2e-setup + with: + python-version: "3.11" + shim-target: ${{ matrix.target }} + + - name: Verify shim is on PATH (POSIX) + if: runner.os != 'Windows' + shell: bash + run: | + which "${{ matrix.target }}" + + - name: Verify shim is on PATH (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + # On Windows the shim is ``.cmd``; Get-Command resolves via + # PATHEXT (same as Python's ``shutil.which`` used by headroom init). + # Git Bash's ``which`` cannot find ``.cmd`` shims, so we use pwsh. + $cmd = Get-Command "${{ matrix.target }}" -ErrorAction Stop + Write-Output $cmd.Source + + - name: Run headroom init -g ${{ matrix.target }} + shell: bash + run: | + set -euo pipefail + headroom init -g "${{ matrix.target }}" + + - name: Assert settings file (POSIX) + if: runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + case "${{ matrix.target }}" in + claude) + test -f "$HOME/.claude/settings.json" + grep -q "ANTHROPIC_BASE_URL" "$HOME/.claude/settings.json" + ;; + codex) + test -f "$HOME/.codex/config.toml" + test -f "$HOME/.codex/hooks.json" + grep -q "headroom" "$HOME/.codex/config.toml" + ;; + copilot) + test -f "$HOME/.copilot/config.json" + grep -q "SessionStart" "$HOME/.copilot/config.json" + ;; + esac + + - name: Assert settings file (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $home_ = $env:USERPROFILE + switch ("${{ matrix.target }}") { + "claude" { + $p = Join-Path $home_ ".claude\settings.json" + if (-not (Test-Path $p)) { throw "Missing $p" } + if (-not ((Get-Content $p -Raw) -match "ANTHROPIC_BASE_URL")) { + throw "settings.json missing ANTHROPIC_BASE_URL" + } + } + "codex" { + $c = Join-Path $home_ ".codex\config.toml" + $h = Join-Path $home_ ".codex\hooks.json" + if (-not (Test-Path $c)) { throw "Missing $c" } + if (-not (Test-Path $h)) { throw "Missing $h" } + if (-not ((Get-Content $c -Raw) -match "headroom")) { + throw "config.toml missing headroom provider" + } + } + "copilot" { + $p = Join-Path $home_ ".copilot\config.json" + if (-not (Test-Path $p)) { throw "Missing $p" } + if (-not ((Get-Content $p -Raw) -match "SessionStart")) { + throw "copilot config missing SessionStart hooks" + } + } + } diff --git a/CHANGELOG.md b/CHANGELOG.md index aa7adef4fbc251e29c5019e4d4b54304d600b6c1..7b82118cdb0d48913ccf741a46bb808d79d4fe65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,14 +8,64 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- **`Learned: error recovery` section in MEMORY.md no longer bloats with + stale or contradictory entries.** The dedup key for error-recovery + patterns was the literal rendered bullet text, so near-duplicate + recoveries (same intent, different `| tail -N` count, same error path + guessed against different successors) each created a new row. There was + also no TTL or re-validation, so wrong-today entries lingered. Fixed by: + (1) normalizing the hash on recovery intent — Read recoveries key on + `(basename(error_path), basename(success_path))`; Bash recoveries strip + volatile suffixes and hash only the primary command before the first + `|`/`&&`; (2) stamping `first_seen_at` / `last_seen_at` on every pattern + and bumping them in `_bump_persisted_evidence` via `json_set`; (3) + refining at render time — drop rows not re-observed in 21 days, + re-validate Read success paths against the filesystem, collapse + same-error_path-with-multiple-targets into one "use Glob/Grep first" + bullet, rank by `evidence_count * 0.5 ** (days/5)`, cap the section at + 15. Other `Learned: …` categories (environment, preference, + architecture) are untouched. +- **`headroom unwrap codex` now actually undoes `headroom wrap codex`** — + previously there was no `unwrap codex` subcommand at all, so the injected + `model_provider = "headroom"` / `[model_providers.headroom]` block stayed + in `~/.codex/config.toml` forever and Codex continued routing through the + (potentially stopped) proxy, surfacing as `Missing environment variable: + OPENAI_API_KEY`. `wrap codex` now snapshots the pre-wrap + `config.toml` to `config.toml.headroom-backup` before its first injection, + and `unwrap codex` restores that snapshot byte-for-byte (or, if the + backup is missing, strips only the Headroom-managed block and leaves + surrounding user content intact). Safe no-op when run without a prior + wrap. Reported by @raenaryl in Discord. - **`headroom learn` no longer clobbers prior recommendations on re-run** — the marker block in `CLAUDE.md` / `MEMORY.md` is now merged with the prior block instead of wholesale-replaced. Sections re-surfaced by the new run win; sections not re-surfaced are carried forward so learnings accumulate across runs instead of disappearing. To fully rebuild the block, delete it manually and re-run. (#231) +- **`headroom learn` no longer emits dangling cross-references when a + section is re-surfaced** — the analyzer now includes the project's + current `` block (from `CLAUDE.md` and + `MEMORY.md`) in the LLM digest as a "Prior Learned Patterns" section, + and the system prompt instructs the LLM that re-emitting a section + replaces the prior one wholesale. Prevents bullets like "`X` is *also* + large — same rule as `Y`, `Z`" from appearing after `Y` and `Z` got + dropped during per-section replacement. The writer's section-level + carry-forward from #231 remains in place as a safety net for sections + the LLM omits entirely. New helper `extract_marker_block` added to + `headroom.learn.writer`. ### Added +- **`turn_id` linking agent-loop API calls to a single user prompt** — a new + `compute_turn_id(model, system, messages)` helper in + `headroom/proxy/helpers.py` hashes the message prefix up to and including + the last user-text message, yielding an id that is stable across every + agent-loop iteration of one prompt but rolls over when the user sends a + new prompt (or runs `/compact`, `/clear`). `RequestLog` gained a + `turn_id: str | None` field, which is stamped at every log site + (anthropic handler bedrock + direct branches, and the streaming handler) + and surfaced as `turn_id` in `/transformations/feed`. Lets downstream + consumers (e.g. the Headroom Desktop Activity tab) aggregate savings per + user prompt rather than per API call. - **Live flush of traffic-learned patterns to CLAUDE.md / MEMORY.md** — the `TrafficLearner` now writes to agent-native context files continuously during proxy operation, not just at shutdown. A new dirty-flag debounced diff --git a/README.md b/README.md index 93fd79821c15bb6d423e19cf23dce51204a4776b..51c7f08af96eb594e2592d9988887dfcc0651eeb 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ **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) +[![codecov](https://codecov.io/gh/chopratejas/headroom/graph/badge.svg)](https://app.codecov.io/gh/chopratejas/headroom) [![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) diff --git a/docs/package-lock.json b/docs/package-lock.json index 3f0e75ae99cab1ee186396663773fc54fb9b83f5..50d9c8bf10520271898a693cfe1e7c8b9e24aae0 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -17,7 +17,7 @@ "fumadocs-ui": "16.7.10", "headroom-ai": "file:../sdk/typescript", "lucide-react": "^1.7.0", - "next": "16.2.2", + "next": "16.2.4", "react": "^19.2.4", "react-dom": "^19.2.4", "recharts": "^3.8.1", @@ -33,7 +33,7 @@ "@types/react-dom": "^19.2.3", "ai": "^6.0.149", "openai": "^6.33.0", - "postcss": "^8.5.8", + "postcss": "^8.5.10", "tailwindcss": "^4.2.2", "typescript": "^5.9.3" } @@ -186,6 +186,80 @@ "node": ">=6.9.0" } }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/darwin-arm64": { "version": "0.27.7", "cpu": [ @@ -200,100 +274,854 @@ "node": ">=18" } }, - "node_modules/@floating-ui/core": { - "version": "1.7.5", + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], "license": "MIT", - "dependencies": { - "@floating-ui/utils": "^0.2.11" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@floating-ui/dom": { - "version": "1.7.6", + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], "license": "MIT", - "dependencies": { - "@floating-ui/core": "^1.7.5", - "@floating-ui/utils": "^0.2.11" + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@floating-ui/react-dom": { - "version": "2.1.8", + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], "license": "MIT", - "dependencies": { - "@floating-ui/dom": "^1.7.6" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@floating-ui/utils": { - "version": "0.2.11", - "license": "MIT" + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@formatjs/fast-memoize": { - "version": "3.1.1", - "license": "MIT" + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@formatjs/intl-localematcher": { - "version": "0.8.2", + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], "license": "MIT", - "dependencies": { - "@formatjs/fast-memoize": "3.1.1" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@fumadocs/tailwind": { - "version": "0.0.3", + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^7.1.1" - }, - "peerDependencies": { - "tailwindcss": "^4.0.0" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "license": "MIT" + }, + "node_modules/@formatjs/fast-memoize": { + "version": "3.1.1", + "license": "MIT" + }, + "node_modules/@formatjs/intl-localematcher": { + "version": "0.8.2", + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "3.1.1" + } + }, + "node_modules/@fumadocs/tailwind": { + "version": "0.0.3", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^7.1.1" + }, + "peerDependencies": { + "tailwindcss": "^4.0.0" + }, + "peerDependenciesMeta": { + "tailwindcss": { + "optional": true + } + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, - "peerDependenciesMeta": { - "tailwindcss": { - "optional": true - } + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@img/colour": { - "version": "1.1.0", - "license": "MIT", + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=18" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@img/sharp-darwin-arm64": { + "node_modules/@img/sharp-win32-ia32": { "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", "cpu": [ - "arm64" + "ia32" ], - "license": "Apache-2.0", + "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ - "darwin" + "win32" ], "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" } }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", "cpu": [ - "arm64" + "x64" ], - "license": "LGPL-3.0-or-later", + "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ - "darwin" + "win32" ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, "funding": { "url": "https://opencollective.com/libvips" } @@ -374,11 +1202,15 @@ } }, "node_modules/@next/env": { - "version": "16.2.2", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.4.tgz", + "integrity": "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==", "license": "MIT" }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.2.2", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.4.tgz", + "integrity": "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==", "cpu": [ "arm64" ], @@ -392,12 +1224,13 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.2.tgz", - "integrity": "sha512-7ZwSgNKJNQiwW0CKhNm9B1WS2L1Olc4B2XY0hPYCAL3epFnugMhuw5TMWzMilQ3QCZcCHoYm9NGWTHbr5REFxw==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.4.tgz", + "integrity": "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==", "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "darwin" @@ -407,12 +1240,13 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.2.tgz", - "integrity": "sha512-c3m8kBHMziMgo2fICOP/cd/5YlrxDU5YYjAJeQLyFsCqVF8xjOTH/QYG4a2u48CvvZZSj1eHQfBCbyh7kBr30Q==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.4.tgz", + "integrity": "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==", "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -422,12 +1256,13 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.2.tgz", - "integrity": "sha512-VKLuscm0P/mIfzt+SDdn2+8TNNJ7f0qfEkA+az7OqQbjzKdBxAHs0UvuiVoCtbwX+dqMEL9U54b5wQ/aN3dHeg==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.4.tgz", + "integrity": "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==", "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -437,12 +1272,13 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.2.tgz", - "integrity": "sha512-kU3OPHJq6sBUjOk7wc5zJ7/lipn8yGldMoAv4z67j6ov6Xo/JvzA7L7LCsyzzsXmgLEhk3Qkpwqaq/1+XpNR3g==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.4.tgz", + "integrity": "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==", "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -452,12 +1288,13 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.2.tgz", - "integrity": "sha512-CKXRILyErMtUftp+coGcZ38ZwE/Aqq45VMCcRLr2I4OXKrgxIBDXHnBgeX/UMil0S09i2JXaDL3Q+TN8D/cKmg==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.4.tgz", + "integrity": "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==", "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -467,12 +1304,13 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.2.tgz", - "integrity": "sha512-sS/jSk5VUoShUqINJFvNjVT7JfR5ORYj/+/ZpOYbbIohv/lQfduWnGAycq2wlknbOql2xOR0DoV0s6Xfcy49+g==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.4.tgz", + "integrity": "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==", "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "win32" @@ -482,12 +1320,13 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.2.tgz", - "integrity": "sha512-aHaKceJgdySReT7qeck5oShucxWRiiEuwCGK8HHALe6yZga8uyFpLkPgaRw3kkF04U7ROogL/suYCNt/+CuXGA==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz", + "integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==", "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "win32" @@ -502,7 +1341,6 @@ "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -1421,73 +2259,337 @@ "node": ">=20" } }, - "node_modules/@shikijs/vscode-textmate": { - "version": "10.0.2", - "license": "MIT" - }, - "node_modules/@standard-schema/spec": { + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.8.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.8.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { "version": "1.1.0", - "license": "MIT" - }, - "node_modules/@standard-schema/utils": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", - "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", - "license": "MIT" + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } }, - "node_modules/@swc/helpers": { - "version": "0.5.15", - "license": "Apache-2.0", + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, "dependencies": { - "tslib": "^2.8.0" + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" } }, - "node_modules/@tailwindcss/node": { - "version": "4.2.2", + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", "dev": true, + "inBundle": true, "license": "MIT", + "optional": true, "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.19.0", - "jiti": "^2.6.1", - "lightningcss": "1.32.0", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.2.2" + "tslib": "^2.4.0" } }, - "node_modules/@tailwindcss/oxide": { + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { "node": ">= 20" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.2", - "@tailwindcss/oxide-darwin-arm64": "4.2.2", - "@tailwindcss/oxide-darwin-x64": "4.2.2", - "@tailwindcss/oxide-freebsd-x64": "4.2.2", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", - "@tailwindcss/oxide-linux-x64-musl": "4.2.2", - "@tailwindcss/oxide-wasm32-wasi": "4.2.2", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", "cpu": [ - "arm64" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "darwin" + "win32" ], "engines": { "node": ">= 20" @@ -1656,15 +2758,13 @@ "node_modules/@types/mdast": { "version": "4.0.4", "license": "MIT", - "peer": true, "dependencies": { "@types/unist": "*" } }, "node_modules/@types/mdx": { "version": "2.0.13", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/ms": { "version": "2.1.0", @@ -1682,7 +2782,6 @@ "version": "19.2.14", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1691,7 +2790,6 @@ "version": "19.2.3", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -1735,7 +2833,6 @@ "node_modules/acorn": { "version": "8.16.0", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2417,7 +3514,6 @@ "node_modules/fumadocs-core": { "version": "16.7.10", "license": "MIT", - "peer": true, "dependencies": { "@formatjs/intl-localematcher": "^0.8.2", "@orama/orama": "^3.1.18", @@ -2655,7 +3751,6 @@ "node_modules/fumadocs-ui": { "version": "16.7.10", "license": "MIT", - "peer": true, "dependencies": { "@fumadocs/tailwind": "0.0.3", "@radix-ui/react-accordion": "^1.2.12", @@ -3059,6 +4154,27 @@ "lightningcss-win32-x64-msvc": "1.32.0" } }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lightningcss-darwin-arm64": { "version": "1.32.0", "cpu": [ @@ -3078,6 +4194,195 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/longest-streak": { "version": "3.1.0", "license": "MIT", @@ -3089,7 +4394,6 @@ "node_modules/lucide-react": { "version": "1.7.0", "license": "ISC", - "peer": true, "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -4106,11 +5410,12 @@ } }, "node_modules/next": { - "version": "16.2.2", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.4.tgz", + "integrity": "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==", "license": "MIT", - "peer": true, "dependencies": { - "@next/env": "16.2.2", + "@next/env": "16.2.4", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -4124,14 +5429,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.2.2", - "@next/swc-darwin-x64": "16.2.2", - "@next/swc-linux-arm64-gnu": "16.2.2", - "@next/swc-linux-arm64-musl": "16.2.2", - "@next/swc-linux-x64-gnu": "16.2.2", - "@next/swc-linux-x64-musl": "16.2.2", - "@next/swc-win32-arm64-msvc": "16.2.2", - "@next/swc-win32-x64-msvc": "16.2.2", + "@next/swc-darwin-arm64": "16.2.4", + "@next/swc-darwin-x64": "16.2.4", + "@next/swc-linux-arm64-gnu": "16.2.4", + "@next/swc-linux-arm64-musl": "16.2.4", + "@next/swc-linux-x64-gnu": "16.2.4", + "@next/swc-linux-x64-musl": "16.2.4", + "@next/swc-win32-arm64-msvc": "16.2.4", + "@next/swc-win32-x64-msvc": "16.2.4", "sharp": "^0.34.5" }, "peerDependencies": { @@ -4305,7 +5610,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", "dev": true, "funding": [ { @@ -4366,7 +5673,6 @@ "node_modules/react": { "version": "19.2.4", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -4374,7 +5680,6 @@ "node_modules/react-dom": { "version": "19.2.4", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4408,7 +5713,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -4594,8 +5898,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -4908,8 +6211,7 @@ "node_modules/tailwindcss": { "version": "4.2.2", "devOptional": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.2", @@ -4998,7 +6300,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5240,7 +6541,6 @@ "node_modules/zod": { "version": "4.3.6", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/docs/package.json b/docs/package.json index 2eded3a4399d0b03b1fbc6974a19a531cf995eea..9c33ac5d330d5567b44aac75877841bd9d836a23 100644 --- a/docs/package.json +++ b/docs/package.json @@ -18,7 +18,7 @@ "fumadocs-ui": "16.7.10", "headroom-ai": "file:../sdk/typescript", "lucide-react": "^1.7.0", - "next": "16.2.2", + "next": "16.2.4", "react": "^19.2.4", "react-dom": "^19.2.4", "recharts": "^3.8.1", @@ -34,7 +34,7 @@ "@types/react-dom": "^19.2.3", "ai": "^6.0.149", "openai": "^6.33.0", - "postcss": "^8.5.8", + "postcss": "^8.5.10", "tailwindcss": "^4.2.2", "typescript": "^5.9.3" } diff --git a/e2e/__init__.py b/e2e/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..32bdd459a5d0f6e424223d8237fdf97db2cf79b8 --- /dev/null +++ b/e2e/__init__.py @@ -0,0 +1,7 @@ +"""End-to-end test suites for Headroom CLI commands. + +Subpackages: + _lib — shared harness and helpers + init — ``headroom init`` coverage + wrap — ``headroom wrap`` coverage +""" diff --git a/e2e/_lib/__init__.py b/e2e/_lib/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..92d9ef70cfb66ff0b89cbd86b2b6b65816a2c90e --- /dev/null +++ b/e2e/_lib/__init__.py @@ -0,0 +1,35 @@ +"""Shared helpers for Docker / CI e2e tests. + +This package centralizes utilities used by the per-command e2e harnesses +(`e2e/init/run.py`, future `e2e/install/run.py`, `e2e/wrap/run.py`, ...). +The goal is that each command test suite is a small declarative file that +imports from this package, so new commands can be covered with minimal +duplication. +""" + +from __future__ import annotations + +from .assertions import ( + assert_exit, + assert_stderr_contains, + assert_stdout_contains, + read_agent_settings, +) +from .harness import Case, CaseContext, run_case_sequence, run_cases +from .path_env import with_clean_path +from .paths import agent_settings_path +from .shims import make_shim + +__all__ = [ + "Case", + "CaseContext", + "agent_settings_path", + "assert_exit", + "assert_stderr_contains", + "assert_stdout_contains", + "make_shim", + "read_agent_settings", + "run_case_sequence", + "run_cases", + "with_clean_path", +] diff --git a/e2e/_lib/assertions.py b/e2e/_lib/assertions.py new file mode 100644 index 0000000000000000000000000000000000000000..79118190c1fed62ca75fda4316528f997b2b6af5 --- /dev/null +++ b/e2e/_lib/assertions.py @@ -0,0 +1,43 @@ +"""Shared assertion helpers for e2e cases. + +Assertions raise ``AssertionError`` with a descriptive message. The harness +catches them and attributes the failure to the owning ``Case``. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from .paths import Agent, Scope, agent_settings_path + + +def assert_exit(actual: int, expected: int, *, context: str = "") -> None: + if actual != expected: + suffix = f" ({context})" if context else "" + raise AssertionError(f"Expected exit code {expected}, got {actual}{suffix}") + + +def assert_stdout_contains(stdout: str, needle: str) -> None: + if needle not in stdout: + raise AssertionError(f"stdout missing {needle!r}:\n---\n{stdout}\n---") + + +def assert_stderr_contains(stderr: str, needle: str) -> None: + if needle not in stderr: + raise AssertionError(f"stderr missing {needle!r}:\n---\n{stderr}\n---") + + +def read_agent_settings( + agent: Agent, *, scope: Scope, home: Path, project: Path +) -> dict[str, Any] | str: + """Read an agent's settings file, returning dict for JSON and str for TOML/other.""" + + path = agent_settings_path(agent, scope=scope, home=home, project=project) + if not path.exists(): + raise AssertionError(f"Expected settings file at {path}, not found") + text = path.read_text(encoding="utf-8") + if path.suffix == ".json": + return json.loads(text) + return text diff --git a/e2e/_lib/harness.py b/e2e/_lib/harness.py new file mode 100644 index 0000000000000000000000000000000000000000..4252c4884b034a02f709f968e3f64ac23f8f1105 --- /dev/null +++ b/e2e/_lib/harness.py @@ -0,0 +1,336 @@ +"""Declarative test-case harness for Docker e2e runners. + +Each command gets its own ``run.py`` file that builds a list of ``Case`` +objects and calls ``run_cases(cases)``. The harness handles: + +* creating a scratch HOME and project directory per case +* dropping the requested shims into a dedicated shim dir +* building a clean PATH that only exposes the shim dir + minimal system dirs +* invoking the ``headroom`` subprocess with the case's argv +* running the case's assertions against stdout / stderr / exit code / files +* reporting pass/fail per case and a final summary + +``run_cases`` returns a non-zero exit code if any case fails, so Docker +containers driving it can fail fast. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import tempfile +from collections.abc import Callable +from dataclasses import dataclass, field +from pathlib import Path + +from .assertions import assert_exit, assert_stderr_contains, assert_stdout_contains +from .path_env import with_clean_path +from .shims import ShimBehavior, make_shim + +CaseCallback = Callable[["CaseContext"], None] + + +@dataclass +class CaseContext: + """Runtime context passed to assertion callbacks.""" + + name: str + home: Path + project: Path + shim_dir: Path + shim_log: Path + stdout: str + stderr: str + exit_code: int + + +@dataclass +class Case: + """Declarative specification of a single e2e test case. + + Attributes: + name: Human-readable identifier, printed on success/failure. + argv: Arguments passed to ``headroom`` (e.g. ``["init", "-g", "claude"]``). + shims: Mapping of shim name -> behavior to drop into the shim dir. + env_extra: Extra env vars layered on top of the clean env. + expected_exit: Required exit code (default 0). + expected_stdout_contains: Substrings that must appear on stdout. + expected_stderr_contains: Substrings that must appear on stderr. + expected_files: Paths (relative to home or project) that must exist. + Use ``{home}/...`` or ``{project}/...`` placeholders. + extra_assertions: Optional list of callbacks invoked after exit-code / + stdout / stderr / file checks pass. Receives a + ``CaseContext``. Use for JSON-content assertions, + shim-log inspection, etc. + """ + + name: str + argv: list[str] + shims: dict[str, ShimBehavior] = field(default_factory=dict) + env_extra: dict[str, str] = field(default_factory=dict) + expected_exit: int = 0 + expected_stdout_contains: list[str] = field(default_factory=list) + expected_stderr_contains: list[str] = field(default_factory=list) + expected_files: list[str] = field(default_factory=list) + extra_assertions: list[CaseCallback] = field(default_factory=list) + + +def _log(message: str) -> None: + print(f"[e2e] {message}", flush=True) + + +def _resolve_placeholder(spec: str, *, home: Path, project: Path) -> Path: + return Path(spec.format(home=str(home), project=str(project))) + + +def _resolve_headroom_bin(name: str) -> str: + """Return the absolute path to the headroom binary before PATH is scrubbed. + + ``with_clean_path`` intentionally narrows PATH so agent shims dominate; + that would also hide the real ``headroom`` binary (typically at + ``/opt/*venv/bin/headroom`` or similar). Resolving up-front lets the + subprocess launch even after PATH is cleaned. + """ + + if os.sep in name or (os.altsep and os.altsep in name): + return name + import shutil + + resolved = shutil.which(name) + if resolved: + return resolved + # Fall back to the bare name; subprocess will raise a clear + # FileNotFoundError that the case output surfaces. + return name + + +def _run_single(case: Case, headroom_bin: str = "headroom") -> bool: + """Execute one case. Return True on pass, False on fail.""" + + with tempfile.TemporaryDirectory(prefix=f"headroom-e2e-{case.name}-") as temp_raw: + temp_root = Path(temp_raw) + home = temp_root / "home" + project = temp_root / "project" + shim_dir = temp_root / "bin" + shim_log = temp_root / "shim-log.jsonl" + home.mkdir(parents=True) + project.mkdir(parents=True) + + for shim_name, behavior in case.shims.items(): + make_shim(shim_name, shim_dir, behavior=behavior) + + # Resolve headroom to its absolute path BEFORE mutating PATH so the + # shim dir can dominate PATH without losing the headroom binary. + resolved_bin = _resolve_headroom_bin(headroom_bin) + + with with_clean_path([shim_dir]) as env: + env["HOME"] = str(home) + env["USERPROFILE"] = str(home) + env["HEADROOM_E2E_SHIM_LOG"] = str(shim_log) + env.update(case.env_extra) + + proc = subprocess.run( + [resolved_bin, *case.argv], + env=env, + cwd=str(project), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=180, + ) + + ctx = CaseContext( + name=case.name, + home=home, + project=project, + shim_dir=shim_dir, + shim_log=shim_log, + stdout=proc.stdout, + stderr=proc.stderr, + exit_code=proc.returncode, + ) + + try: + assert_exit(proc.returncode, case.expected_exit, context=f"case {case.name}") + for needle in case.expected_stdout_contains: + assert_stdout_contains(proc.stdout, needle) + for needle in case.expected_stderr_contains: + assert_stderr_contains(proc.stderr, needle) + for spec in case.expected_files: + path = _resolve_placeholder(spec, home=home, project=project) + if not path.exists(): + raise AssertionError(f"Expected file {path} not found") + for callback in case.extra_assertions: + callback(ctx) + except AssertionError as exc: + _log(f"FAIL {case.name}: {exc}") + if proc.stdout.strip(): + _log(f" stdout: {proc.stdout.rstrip()}") + if proc.stderr.strip(): + _log(f" stderr: {proc.stderr.rstrip()}") + return False + + _log(f"PASS {case.name}") + return True + + +def _run_in_scratch( + case: Case, + *, + home: Path, + project: Path, + shim_dir: Path, + shim_log: Path, + headroom_bin: str, +) -> bool: + """Execute one case inside a pre-existing scratch layout. + + Shims are *added* to ``shim_dir`` (existing shims from prior sequence + steps are preserved). This enables sequence cases to build up shim state. + """ + + for shim_name, behavior in case.shims.items(): + make_shim(shim_name, shim_dir, behavior=behavior) + + resolved_bin = _resolve_headroom_bin(headroom_bin) + + with with_clean_path([shim_dir]) as env: + env["HOME"] = str(home) + env["USERPROFILE"] = str(home) + env["HEADROOM_E2E_SHIM_LOG"] = str(shim_log) + env.update(case.env_extra) + + proc = subprocess.run( + [resolved_bin, *case.argv], + env=env, + cwd=str(project), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=180, + ) + + ctx = CaseContext( + name=case.name, + home=home, + project=project, + shim_dir=shim_dir, + shim_log=shim_log, + stdout=proc.stdout, + stderr=proc.stderr, + exit_code=proc.returncode, + ) + + try: + assert_exit(proc.returncode, case.expected_exit, context=f"case {case.name}") + for needle in case.expected_stdout_contains: + assert_stdout_contains(proc.stdout, needle) + for needle in case.expected_stderr_contains: + assert_stderr_contains(proc.stderr, needle) + for spec in case.expected_files: + path = _resolve_placeholder(spec, home=home, project=project) + if not path.exists(): + raise AssertionError(f"Expected file {path} not found") + for callback in case.extra_assertions: + callback(ctx) + except AssertionError as exc: + _log(f"FAIL {case.name}: {exc}") + if proc.stdout.strip(): + _log(f" stdout: {proc.stdout.rstrip()}") + if proc.stderr.strip(): + _log(f" stderr: {proc.stderr.rstrip()}") + return False + + _log(f"PASS {case.name}") + return True + + +def run_cases( + cases: list[Case], + *, + headroom_bin: str = "headroom", + fail_fast: bool = False, +) -> int: + """Run each case in its own scratch dir. Return exit code (0 = all pass).""" + + passed = 0 + failed = 0 + for case in cases: + ok = _run_single(case, headroom_bin=headroom_bin) + if ok: + passed += 1 + else: + failed += 1 + if fail_fast: + break + + _log(f"Summary: {passed} passed, {failed} failed, {len(cases)} total") + return 0 if failed == 0 else 1 + + +def run_case_sequence( + cases: list[Case], + *, + headroom_bin: str = "headroom", + label: str = "sequence", + fail_fast: bool = True, +) -> int: + """Run cases sequentially inside a single shared scratch dir. + + Useful when later cases must observe state left by earlier ones (e.g. + ``headroom init`` accumulating targets in a shared manifest across + successive calls). + """ + + passed = 0 + failed = 0 + with tempfile.TemporaryDirectory(prefix=f"headroom-e2e-{label}-") as temp_raw: + temp_root = Path(temp_raw) + home = temp_root / "home" + project = temp_root / "project" + shim_dir = temp_root / "bin" + shim_log = temp_root / "shim-log.jsonl" + home.mkdir(parents=True) + project.mkdir(parents=True) + + for case in cases: + ok = _run_in_scratch( + case, + home=home, + project=project, + shim_dir=shim_dir, + shim_log=shim_log, + headroom_bin=headroom_bin, + ) + if ok: + passed += 1 + else: + failed += 1 + if fail_fast: + break + + _log(f"Summary ({label}): {passed} passed, {failed} failed, {len(cases)} total") + return 0 if failed == 0 else 1 + + +# Allow callers to adopt a different exit strategy (e.g. raising) easily. +def main_from_cases(cases: list[Case]) -> None: + """Convenience entry point for ``run.py`` scripts.""" + + code = run_cases(cases) + sys.exit(code) + + +__all__ = [ + "Case", + "CaseContext", + "main_from_cases", + "run_case_sequence", + "run_cases", +] + +# Silence unused-import lint for re-exports used by callers. +_ = os diff --git a/e2e/_lib/make_shim.ps1 b/e2e/_lib/make_shim.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..4ed586af75287181792324d57072d89d4e3893cb --- /dev/null +++ b/e2e/_lib/make_shim.ps1 @@ -0,0 +1,24 @@ +# Create a noop executable shim at $Dir\$Name.cmd for use in PATH during +# native (non-Docker) e2e tests on Windows. Mirrors e2e/_lib/shims.py +# make_shim(noop). +# +# Usage: make_shim.ps1 -Name -Dir + +param( + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][string]$Dir +) + +$ErrorActionPreference = "Stop" + +if (-not (Test-Path $Dir)) { + New-Item -ItemType Directory -Path $Dir -Force | Out-Null +} + +$path = Join-Path $Dir "$Name.cmd" +$content = @" +@echo off +exit /b 0 +"@ +Set-Content -Path $path -Value $content -Encoding ASCII -NoNewline +Write-Output $path diff --git a/e2e/_lib/make_shim.sh b/e2e/_lib/make_shim.sh new file mode 100644 index 0000000000000000000000000000000000000000..6820f3c58c4bba6e77a3ef360060cf66805f195d --- /dev/null +++ b/e2e/_lib/make_shim.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Create a noop executable shim at $2/$1 suitable for use in PATH during +# native (non-Docker) e2e tests. Mirrors e2e/_lib/shims.py make_shim(noop). +# +# Usage: make_shim.sh +# +# Exit codes: +# 0 on success +# 2 on usage error + +set -euo pipefail + +if [ $# -ne 2 ]; then + echo "usage: $0 " >&2 + exit 2 +fi + +name="$1" +dir="$2" + +mkdir -p "$dir" +path="$dir/$name" +cat >"$path" <<'EOS' +#!/usr/bin/env bash +exit 0 +EOS +chmod +x "$path" +echo "$path" diff --git a/e2e/_lib/path_env.py b/e2e/_lib/path_env.py new file mode 100644 index 0000000000000000000000000000000000000000..e9baa1aea396de572ccc3312de4fd0590805d4b3 --- /dev/null +++ b/e2e/_lib/path_env.py @@ -0,0 +1,54 @@ +"""PATH environment helpers for e2e test isolation.""" + +from __future__ import annotations + +import os +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path + + +def _minimal_path_dirs() -> list[str]: + """Directories always needed so Python / basic shell utilities work.""" + + if os.name == "nt": + system_root = os.environ.get("SystemRoot", r"C:\Windows") + return [ + rf"{system_root}\System32", + system_root, + rf"{system_root}\System32\Wbem", + rf"{system_root}\System32\WindowsPowerShell\v1.0", + ] + # POSIX: keep enough for bash, python3, mkdir, chmod, etc. + return ["/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"] + + +@contextmanager +def with_clean_path(extra_dirs: list[Path] | None = None) -> Iterator[dict[str, str]]: + """Set PATH to a minimal known-good value plus ``extra_dirs``. + + Yields the (already-mutated) environment dict so callers can pass it + directly to ``subprocess.run(env=...)``. On exit, the previous PATH is + restored. + """ + + extras = [str(Path(p)) for p in (extra_dirs or [])] + new_path = os.pathsep.join(extras + _minimal_path_dirs()) + env = os.environ.copy() + previous = env.get("PATH") + env["PATH"] = new_path + # Also mutate the real environment so ``shutil.which`` inside this process + # sees the clean PATH. Restore on exit. + real_previous = os.environ.get("PATH") + os.environ["PATH"] = new_path + try: + yield env + finally: + if real_previous is None: + os.environ.pop("PATH", None) + else: + os.environ["PATH"] = real_previous + if previous is None: + env.pop("PATH", None) + else: + env["PATH"] = previous diff --git a/e2e/_lib/paths.py b/e2e/_lib/paths.py new file mode 100644 index 0000000000000000000000000000000000000000..ccfc8a75136a403cb3581f675088fa4291a086e2 --- /dev/null +++ b/e2e/_lib/paths.py @@ -0,0 +1,47 @@ +"""Per-agent settings-file locators for e2e assertions. + +These paths mirror the logic in ``headroom.cli.init`` so e2e tests can +verify that the right file was written without importing private init +helpers. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Literal + +Agent = Literal["claude", "codex", "copilot", "openclaw"] +Scope = Literal["user", "local"] + + +def agent_settings_path(agent: Agent, *, scope: Scope, home: Path, project: Path) -> Path: + """Return the file that ``headroom init`` should have written for ``agent``. + + ``home`` is the test's simulated HOME directory and ``project`` is the cwd + used when invoking ``headroom init``. For global (``-g``) invocations only + ``home`` matters; for local invocations only ``project`` matters. + """ + + home = Path(home) + project = Path(project) + + if agent == "claude": + if scope == "user": + return home / ".claude" / "settings.json" + return project / ".claude" / "settings.local.json" + + if agent == "codex": + if scope == "user": + return home / ".codex" / "config.toml" + return project / ".codex" / "config.toml" + + if agent == "copilot": + # Copilot init requires -g; no local scope. + return home / ".copilot" / "config.json" + + if agent == "openclaw": + # OpenClaw init is delegated to `headroom wrap openclaw`; it writes + # the openclaw json under $HOME. + return home / ".openclaw" / "openclaw.json" + + raise ValueError(f"Unknown agent: {agent!r}") diff --git a/e2e/_lib/shims.py b/e2e/_lib/shims.py new file mode 100644 index 0000000000000000000000000000000000000000..26a50be2991f9ecba08947c3c4e5aa305b76eb1b --- /dev/null +++ b/e2e/_lib/shims.py @@ -0,0 +1,96 @@ +"""Cross-platform agent binary shim factory for e2e tests. + +A "shim" is a tiny executable with a given name (e.g. `claude`, `codex`) that +the harness drops into a temporary directory and prepends to PATH. It lets +tests drive `headroom init` without requiring a real Claude/Codex install. + +Three behaviors are supported: + +* ``noop`` — exits 0 with no output. Default. +* ``fail`` — exits 1 with a short stderr message. +* ``record-args`` — appends a JSON record of (tool, argv, cwd) to the file at + ``$HEADROOM_E2E_SHIM_LOG``, then exits 0. Useful for + asserting that `init claude` invoked + `claude plugin install` with the right arguments. +""" + +from __future__ import annotations + +import os +import stat +import sys +from pathlib import Path +from typing import Literal + +ShimBehavior = Literal["noop", "fail", "record-args"] + +_NOOP_SH = """#!/usr/bin/env bash +exit 0 +""" + +_FAIL_SH = """#!/usr/bin/env bash +echo "${0##*/}: simulated failure" >&2 +exit 1 +""" + +_RECORD_SH = """#!/usr/bin/env bash +tool="${0##*/}" +log="${HEADROOM_E2E_SHIM_LOG:-/dev/null}" +mkdir -p "$(dirname "$log")" 2>/dev/null || true +python3 - "$tool" "$log" "$@" <<'PY' +import json, os, sys +tool, log, *argv = sys.argv[1:] +record = {"tool": tool, "argv": argv, "cwd": os.getcwd()} +if log != "/dev/null": + with open(log, "a", encoding="utf-8") as handle: + handle.write(json.dumps(record) + "\\n") +print(f"{tool} shim executed") +PY +exit 0 +""" + +# Windows equivalents. Use `.cmd` so `shutil.which` and PATHEXT find them. +_NOOP_CMD = "@echo off\r\nexit /b 0\r\n" + +_FAIL_CMD = "@echo off\r\necho %~n0: simulated failure 1>&2\r\nexit /b 1\r\n" + +_RECORD_CMD = ( + "@echo off\r\n" + "setlocal\r\n" + 'if "%HEADROOM_E2E_SHIM_LOG%"=="" set HEADROOM_E2E_SHIM_LOG=NUL\r\n' + "python -c \"import json,os,sys; name=r'%~n0'; log=os.environ['HEADROOM_E2E_SHIM_LOG']; " + "rec={'tool':name,'argv':sys.argv[1:],'cwd':os.getcwd()};\r\n" + "open(log,'a',encoding='utf-8').write(json.dumps(rec)+chr(10)) if log!='NUL' else None;\r\n" + "print(f'{name} shim executed')\" %*\r\n" + "exit /b 0\r\n" +) + + +def _is_windows() -> bool: + return os.name == "nt" or sys.platform == "win32" + + +def make_shim(name: str, dir: Path, behavior: ShimBehavior = "noop") -> Path: + """Create an executable shim named ``name`` inside ``dir``. + + Returns the absolute path to the created shim. On POSIX this is a ``.sh`` + file made executable and named without extension (so ``shutil.which(name)`` + finds it). On Windows this is a ``.cmd`` file — again, ``shutil.which`` + honours ``PATHEXT`` and will find it. + """ + + dir = Path(dir) + dir.mkdir(parents=True, exist_ok=True) + + if _is_windows(): + body = {"noop": _NOOP_CMD, "fail": _FAIL_CMD, "record-args": _RECORD_CMD}[behavior] + path = dir / f"{name}.cmd" + path.write_text(body, encoding="utf-8") + return path + + body = {"noop": _NOOP_SH, "fail": _FAIL_SH, "record-args": _RECORD_SH}[behavior] + path = dir / name + path.write_text(body, encoding="utf-8") + mode = path.stat().st_mode + path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + return path diff --git a/e2e/init/Dockerfile b/e2e/init/Dockerfile index 5836d3ef4bce204a9dc572821f429757ef4d3c6d..e14acd4d54fde62e74038f503b151693b8bf50f1 100644 --- a/e2e/init/Dockerfile +++ b/e2e/init/Dockerfile @@ -24,10 +24,15 @@ COPY headroom ./headroom COPY .claude-plugin ./.claude-plugin COPY .github/plugin ./.github/plugin COPY plugins/headroom-agent-hooks ./plugins/headroom-agent-hooks +# The init e2e harness imports from e2e._lib; both directories must be +# present and each must contain an __init__.py so Python sees them as +# packages rooted at /workspace. +COPY e2e/__init__.py ./e2e/__init__.py +COPY e2e/_lib ./e2e/_lib COPY e2e/init ./e2e/init RUN python -m venv /opt/headroom-venv && \ - /opt/headroom-venv/bin/python -m pip install --upgrade pip && \ + /opt/headroom-venv/bin/python -m pip install --upgrade "pip<25" && \ /opt/headroom-venv/bin/python -m pip install -e ".[proxy]" CMD ["python", "e2e/init/run.py"] diff --git a/e2e/init/run.py b/e2e/init/run.py index 4a1b14b341a41ed7ad07659205bd35c4c97543aa..d7931704c5acb1a50a0ea21dc849b5946494e98d 100644 --- a/e2e/init/run.py +++ b/e2e/init/run.py @@ -1,236 +1,336 @@ -from __future__ import annotations - -import json -import os -import stat -import subprocess -import sys -import tempfile -import textwrap -from pathlib import Path - -from headroom.cli import init as init_cli - -REPO_ROOT = Path("/workspace") -HEADROOM = "headroom" - - -def log(message: str) -> None: - print(f"[init-e2e] {message}", flush=True) - - -def run( - cmd: list[str], - *, - env: dict[str, str], - cwd: Path, - timeout: int = 180, -) -> subprocess.CompletedProcess[str]: - log(f"$ {' '.join(cmd)}") - result = subprocess.run( - cmd, - env=env, - cwd=str(cwd), - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=timeout, - ) - if result.stdout.strip(): - print(result.stdout.rstrip(), flush=True) - if result.stderr.strip(): - print(result.stderr.rstrip(), file=sys.stderr, flush=True) - if result.returncode != 0: - raise RuntimeError(f"Command failed with exit code {result.returncode}: {' '.join(cmd)}") - return result - - -def assert_true(condition: bool, message: str) -> None: - if not condition: - raise AssertionError(message) - - -def write_executable(path: Path, content: str) -> None: - path.write_text(content, encoding="utf-8") - path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) - - -def read_jsonl(path: Path) -> list[dict[str, object]]: - if not path.exists(): - return [] - return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line] - - -def create_agent_shims(shim_dir: Path, log_path: Path) -> None: - shim = textwrap.dedent( - """\ - #!/usr/bin/env python3 - from __future__ import annotations - - import json - import os - import sys - from pathlib import Path - - record = { - "tool": Path(sys.argv[0]).name, - "argv": sys.argv[1:], - "cwd": os.getcwd(), - } - log_path = Path(os.environ["HEADROOM_INIT_E2E_LOG"]) - log_path.parent.mkdir(parents=True, exist_ok=True) - with log_path.open("a", encoding="utf-8") as handle: - handle.write(json.dumps(record) + "\\n") - print(f"{record['tool']} shim executed") - raise SystemExit(0) - """ - ) - shim_dir.mkdir(parents=True, exist_ok=True) - for name in ("claude", "copilot"): - write_executable(shim_dir / name, shim) - - -def expect_hook_command(command: str, profile: str) -> None: - assert_true("init hook ensure" in command, f"missing init hook ensure in: {command}") - assert_true(f"--profile {profile}" in command, f"missing profile {profile} in: {command}") - - -def read_manifest(home_dir: Path, profile: str) -> dict[str, object]: - path = home_dir / ".headroom" / "deploy" / profile / "manifest.json" - assert_true(path.exists(), f"Expected manifest at {path}") - return json.loads(path.read_text(encoding="utf-8")) - - -def verify_claude_local(home_dir: Path, project_dir: Path, shim_log: Path) -> None: - settings = json.loads( - (project_dir / ".claude" / "settings.local.json").read_text(encoding="utf-8") - ) - assert_true( - settings["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:9011", - "Claude local settings should point at the requested proxy port", - ) - session_start = settings["hooks"]["SessionStart"][0]["hooks"][0]["command"] - pre_tool = settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"] - profile = init_cli._local_profile(project_dir) - expect_hook_command(session_start, profile) - expect_hook_command(pre_tool, profile) - - manifest = read_manifest(home_dir, profile) - assert_true("claude" in manifest["targets"], "Claude init should register the claude target") - - claude_calls = [record["argv"] for record in read_jsonl(shim_log) if record["tool"] == "claude"] - assert_true( - claude_calls - == [ - ["plugin", "marketplace", "add", str(REPO_ROOT)], - ["plugin", "install", "headroom@headroom-marketplace", "--scope", "local"], - ], - f"Unexpected Claude install commands: {claude_calls}", - ) - - -def verify_copilot_global(home_dir: Path, shim_log: Path) -> None: - config = json.loads((home_dir / ".copilot" / "config.json").read_text(encoding="utf-8")) - assert_true( - "SessionStart" in config["hooks"], "Copilot config should include SessionStart hooks" - ) - assert_true("PreToolUse" in config["hooks"], "Copilot config should include PreToolUse hooks") - session_start = config["hooks"]["SessionStart"][0]["command"] - expect_hook_command(session_start, "init-user") - - for shell_file in (home_dir / ".bashrc", home_dir / ".zshrc", home_dir / ".profile"): - content = shell_file.read_text(encoding="utf-8") - assert_true( - 'export COPILOT_PROVIDER_TYPE="openai"' in content, - f"{shell_file.name} should contain the Copilot provider type", - ) - assert_true( - 'export COPILOT_PROVIDER_BASE_URL="http://127.0.0.1:9005/v1"' in content, - f"{shell_file.name} should contain the Copilot provider base URL", - ) - assert_true( - 'export COPILOT_PROVIDER_WIRE_API="completions"' in content, - f"{shell_file.name} should contain the Copilot wire API", - ) - - copilot_calls = [ - record["argv"] for record in read_jsonl(shim_log) if record["tool"] == "copilot" - ] - assert_true( - copilot_calls - == [ - ["plugin", "marketplace", "add", str(REPO_ROOT)], - ["plugin", "install", "headroom@headroom-marketplace"], - ], - f"Unexpected Copilot install commands: {copilot_calls}", - ) - - -def verify_codex_local(home_dir: Path, project_dir: Path) -> None: - config_path = project_dir / ".codex" / "config.toml" - hooks_path = project_dir / ".codex" / "hooks.json" - config = config_path.read_text(encoding="utf-8") - hooks = json.loads(hooks_path.read_text(encoding="utf-8")) - profile = init_cli._local_profile(project_dir) - - assert_true( - 'base_url = "http://127.0.0.1:9012/v1"' in config, - "Codex config should point at the requested proxy port", - ) - assert_true( - config.count("[features]") == 1, "Codex config should keep a single [features] table" - ) - assert_true("codex_hooks = true" in config, "Codex config should enable codex_hooks") - command = hooks["hooks"]["SessionStart"][0]["hooks"][0]["command"] - expect_hook_command(command, profile) - - manifest = read_manifest(home_dir, profile) - targets = manifest["targets"] - assert_true(set(targets) == {"claude", "codex"}, f"Unexpected merged targets: {targets}") - - -def main() -> None: - with tempfile.TemporaryDirectory(prefix="headroom-init-e2e-") as temp_root_raw: - temp_root = Path(temp_root_raw) - home_dir = temp_root / "home" - project_dir = temp_root / "project" - shim_dir = temp_root / "bin" - shim_log = temp_root / "shim-log.jsonl" - home_dir.mkdir(parents=True) - project_dir.mkdir(parents=True) - create_agent_shims(shim_dir, shim_log) - - env = os.environ.copy() - env["HOME"] = str(home_dir) - env["USERPROFILE"] = str(home_dir) - env["HEADROOM_INIT_E2E_LOG"] = str(shim_log) - env["PATH"] = f"{shim_dir}:{env['PATH']}" - - run([HEADROOM, "init", "--port", "9011", "claude"], env=env, cwd=project_dir) - verify_claude_local(home_dir, project_dir, shim_log) - - run( - [ - HEADROOM, - "init", - "-g", - "--port", - "9005", - "--backend", - "openai", - "copilot", - ], - env=env, - cwd=project_dir, - ) - verify_copilot_global(home_dir, shim_log) - - run([HEADROOM, "init", "--port", "9012", "codex"], env=env, cwd=project_dir) - verify_codex_local(home_dir, project_dir) - - log("Init e2e completed successfully") - - -if __name__ == "__main__": - main() +"""Docker e2e cases for ``headroom init``. + +Every case is described declaratively with :class:`Case` from +``e2e/_lib/harness.py``. Three groups run in order: + +1. **existing sequence**: preserves the original scenario that exercised + ``headroom init claude`` (local) -> ``init -g copilot`` (global) -> + ``init codex`` (local), sharing scratch state so manifest-merge is + exercised end-to-end. +2. **bare ``init -g`` detection**: verifies the UX regression from #245 + stays fixed — both "no shims found" (friendly error, exit 1) and + "all shims found" (exit 0, all four agents configured). +3. **per-subcommand**: one case per ``init -g `` with only that + agent's shim on PATH, so the explicit path is covered independently. + +The fourth group covers ``--verbose`` output going to stderr. + +Run directly: ``python e2e/init/run.py`` (inside the Docker image built +from ``e2e/init/Dockerfile``). +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +# Add repo root to sys.path so the harness import works whether the file is +# invoked as ``python e2e/init/run.py`` or ``python -m e2e.init.run``. +_REPO_ROOT = Path(__file__).resolve().parents[2] +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) + +from e2e._lib import ( # noqa: E402 + Case, + CaseContext, + run_case_sequence, + run_cases, +) +from headroom.cli import init as init_cli # noqa: E402 + +# ----- helpers reused across cases -------------------------------------------- + +# Docker image builds the workspace at /workspace; the marketplace source +# falls back to that repo checkout when a local marketplace manifest is found. +REPO_ROOT_IN_CONTAINER = Path("/workspace") + + +def _read_jsonl(path: Path) -> list[dict[str, object]]: + if not path.exists(): + return [] + return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line] + + +def _expect_hook_command(command: str, profile: str) -> None: + if "init hook ensure" not in command: + raise AssertionError(f"missing 'init hook ensure' in: {command}") + if f"--profile {profile}" not in command: + raise AssertionError(f"missing '--profile {profile}' in: {command}") + + +def _read_manifest(home: Path, profile: str) -> dict[str, object]: + path = home / ".headroom" / "deploy" / profile / "manifest.json" + if not path.exists(): + raise AssertionError(f"Expected manifest at {path}") + return json.loads(path.read_text(encoding="utf-8")) + + +# ----- existing-flow assertions (ported verbatim from the old run.py) --------- + + +def _verify_claude_local(ctx: CaseContext) -> None: + settings_path = ctx.project / ".claude" / "settings.local.json" + settings = json.loads(settings_path.read_text(encoding="utf-8")) + if settings["env"]["ANTHROPIC_BASE_URL"] != "http://127.0.0.1:9011": + raise AssertionError( + f"Claude local settings should point at port 9011, got " + f"{settings['env']['ANTHROPIC_BASE_URL']!r}" + ) + session_start = settings["hooks"]["SessionStart"][0]["hooks"][0]["command"] + pre_tool = settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"] + profile = init_cli._local_profile(ctx.project) + _expect_hook_command(session_start, profile) + _expect_hook_command(pre_tool, profile) + + manifest = _read_manifest(ctx.home, profile) + if "claude" not in manifest["targets"]: + raise AssertionError( + f"Claude init should register the claude target, got {manifest['targets']}" + ) + + claude_calls = [ + record["argv"] for record in _read_jsonl(ctx.shim_log) if record["tool"] == "claude" + ] + expected = [ + ["plugin", "marketplace", "add", str(REPO_ROOT_IN_CONTAINER)], + ["plugin", "install", "headroom@headroom-marketplace", "--scope", "local"], + ] + if claude_calls != expected: + raise AssertionError(f"Unexpected Claude install commands: {claude_calls}") + + +def _verify_copilot_global(ctx: CaseContext) -> None: + config = json.loads((ctx.home / ".copilot" / "config.json").read_text(encoding="utf-8")) + if "SessionStart" not in config["hooks"]: + raise AssertionError("Copilot config missing SessionStart hooks") + if "PreToolUse" not in config["hooks"]: + raise AssertionError("Copilot config missing PreToolUse hooks") + session_start = config["hooks"]["SessionStart"][0]["command"] + _expect_hook_command(session_start, "init-user") + + for shell_file in (ctx.home / ".bashrc", ctx.home / ".zshrc", ctx.home / ".profile"): + content = shell_file.read_text(encoding="utf-8") + for literal in ( + 'export COPILOT_PROVIDER_TYPE="openai"', + 'export COPILOT_PROVIDER_BASE_URL="http://127.0.0.1:9005/v1"', + 'export COPILOT_PROVIDER_WIRE_API="completions"', + ): + if literal not in content: + raise AssertionError(f"{shell_file.name} missing {literal!r}") + + copilot_calls = [ + record["argv"] for record in _read_jsonl(ctx.shim_log) if record["tool"] == "copilot" + ] + expected = [ + ["plugin", "marketplace", "add", str(REPO_ROOT_IN_CONTAINER)], + ["plugin", "install", "headroom@headroom-marketplace"], + ] + if copilot_calls != expected: + raise AssertionError(f"Unexpected Copilot install commands: {copilot_calls}") + + +def _verify_codex_local(ctx: CaseContext) -> None: + config = (ctx.project / ".codex" / "config.toml").read_text(encoding="utf-8") + hooks = json.loads((ctx.project / ".codex" / "hooks.json").read_text(encoding="utf-8")) + profile = init_cli._local_profile(ctx.project) + + if 'base_url = "http://127.0.0.1:9012/v1"' not in config: + raise AssertionError("Codex config should point at the requested proxy port (9012)") + if config.count("[features]") != 1: + raise AssertionError("Codex config should keep a single [features] table") + if "codex_hooks = true" not in config: + raise AssertionError("Codex config should enable codex_hooks") + command = hooks["hooks"]["SessionStart"][0]["hooks"][0]["command"] + _expect_hook_command(command, profile) + + manifest = _read_manifest(ctx.home, profile) + targets = manifest["targets"] + if set(targets) != {"claude", "codex"}: + raise AssertionError(f"Unexpected merged targets: {targets}") + + +# ----- new cases (issue #245 fix + per-subcommand coverage) ------------------- + + +def _verify_claude_global(ctx: CaseContext) -> None: + settings = json.loads((ctx.home / ".claude" / "settings.json").read_text(encoding="utf-8")) + if settings["env"]["ANTHROPIC_BASE_URL"] != "http://127.0.0.1:8787": + raise AssertionError( + f"Claude user settings should default to port 8787, got " + f"{settings['env']['ANTHROPIC_BASE_URL']!r}" + ) + _expect_hook_command( + settings["hooks"]["SessionStart"][0]["hooks"][0]["command"], + init_cli._GLOBAL_PROFILE, + ) + + +def _verify_codex_global(ctx: CaseContext) -> None: + config = (ctx.home / ".codex" / "config.toml").read_text(encoding="utf-8") + if 'base_url = "http://127.0.0.1:8787/v1"' not in config: + raise AssertionError("Codex user config should point at port 8787 by default") + if "codex_hooks = true" not in config: + raise AssertionError("Codex user config should enable codex_hooks") + hooks = json.loads((ctx.home / ".codex" / "hooks.json").read_text(encoding="utf-8")) + _expect_hook_command( + hooks["hooks"]["SessionStart"][0]["hooks"][0]["command"], + init_cli._GLOBAL_PROFILE, + ) + + +# ----- case tables ------------------------------------------------------------ + + +def existing_sequence_cases() -> list[Case]: + """Preserves the original run.py scenario in one shared scratch.""" + + return [ + Case( + name="seq_claude_local", + argv=["init", "--port", "9011", "claude"], + shims={"claude": "record-args", "copilot": "record-args"}, + expected_exit=0, + expected_stdout_contains=["Configured Claude Code (local scope)"], + extra_assertions=[_verify_claude_local], + ), + Case( + name="seq_copilot_global", + argv=["init", "-g", "--port", "9005", "--backend", "openai", "copilot"], + shims={}, # reuse shims from prior case in the sequence + expected_exit=0, + expected_stdout_contains=["Configured GitHub Copilot CLI (user scope)"], + extra_assertions=[_verify_copilot_global], + ), + Case( + name="seq_codex_local", + argv=["init", "--port", "9012", "codex"], + shims={}, + expected_exit=0, + expected_stdout_contains=["Configured Codex (local scope)"], + extra_assertions=[_verify_codex_local], + ), + ] + + +def bare_init_g_cases() -> list[Case]: + """Bare ``headroom init -g`` — the direct coverage of issue #245.""" + + return [ + Case( + name="bare_init_g_no_shims", + argv=["init", "-g"], + shims={}, # nothing on PATH + expected_exit=1, + expected_stderr_contains=[ + # every target should be listed so the user knows what was tried + "claude", + "codex", + "copilot", + "openclaw", + # concrete escape hatch — exactly what the user should type next + "headroom init -g claude", + # confirm -g itself is still the right flag + "-g", + ], + ), + Case( + name="bare_init_g_with_all_shims", + argv=["init", "-g"], + shims={ + "claude": "record-args", + "codex": "noop", + "copilot": "record-args", + "openclaw": "noop", + }, + expected_exit=0, + expected_stdout_contains=[ + "Configured Claude Code (user scope)", + "Configured GitHub Copilot CLI (user scope)", + "Configured Codex (user scope)", + ], + ), + ] + + +def per_subcommand_cases() -> list[Case]: + """One case per ``headroom init -g `` with only that agent's shim.""" + + return [ + Case( + name="init_g_claude_explicit", + argv=["init", "-g", "claude"], + shims={"claude": "record-args"}, + expected_exit=0, + expected_stdout_contains=["Configured Claude Code (user scope)"], + expected_files=["{home}/.claude/settings.json"], + extra_assertions=[_verify_claude_global], + ), + Case( + name="init_g_codex_explicit", + argv=["init", "-g", "codex"], + shims={"codex": "noop"}, + expected_exit=0, + expected_stdout_contains=["Configured Codex (user scope)"], + expected_files=[ + "{home}/.codex/config.toml", + "{home}/.codex/hooks.json", + ], + extra_assertions=[_verify_codex_global], + ), + Case( + name="init_g_copilot_explicit", + argv=["init", "-g", "copilot"], + shims={"copilot": "record-args"}, + expected_exit=0, + expected_stdout_contains=["Configured GitHub Copilot CLI (user scope)"], + expected_files=["{home}/.copilot/config.json"], + ), + # openclaw delegates to `headroom wrap openclaw` which has its own + # (more expensive) init path and isn't stubbable with a simple shim. + # We assert it fails fast with a clear error when not installed, and + # rely on the `bare_init_g_with_all_shims` case (which uses a noop + # openclaw shim + claude/codex/copilot shims) to cover the success + # path alongside the other agents. + Case( + name="init_g_openclaw_missing", + argv=["init", "-g", "openclaw"], + shims={}, + expected_exit=1, + ), + ] + + +def verbose_cases() -> list[Case]: + """Verbose flag smoke tests — debug lines should appear on stderr.""" + + return [ + Case( + name="init_verbose_no_shims", + argv=["init", "-v", "-g"], + shims={}, + expected_exit=1, + expected_stderr_contains=[ + # A few structural markers from the verbose log. Kept loose so + # minor wording tweaks don't break the test. + "detect_init_targets", + "claude", + "global_scope=True", + ], + ), + ] + + +def main() -> None: + rc = 0 + rc |= run_case_sequence(existing_sequence_cases(), label="existing-sequence") + rc |= run_cases(bare_init_g_cases()) + rc |= run_cases(per_subcommand_cases()) + rc |= run_cases(verbose_cases()) + if rc != 0: + raise SystemExit(rc) + print("[e2e] init e2e completed successfully", flush=True) + + +if __name__ == "__main__": + main() diff --git a/headroom/cli/init.py b/headroom/cli/init.py index 09767fc65b2189f173f4be4cd9214d442bbda9bc..6c57581e94fc4f88ec052beb5ce5917efa00c9b2 100644 --- a/headroom/cli/init.py +++ b/headroom/cli/init.py @@ -1,679 +1,817 @@ -"""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 "chopratejas/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) +"""Durable agent initialization commands.""" + +from __future__ import annotations + +import json +import logging +import os +import shlex +import shutil +import subprocess +import sys +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 + +logger = logging.getLogger(__name__) + +_VERBOSE_HANDLER_ATTR = "_headroom_init_verbose_handler" + +_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 _enable_verbose_logging() -> None: + """Attach a stderr handler to the init logger at DEBUG level. + + Idempotent: calling this multiple times in one process (e.g. when nested + subcommands are invoked) leaves exactly one handler attached. Does NOT + mutate stdout; all verbose output goes to stderr so ``headroom init`` + can still be composed in pipes that consume stdout. + """ + + if getattr(logger, _VERBOSE_HANDLER_ATTR, None) is not None: + return + handler = logging.StreamHandler(stream=sys.stderr) + handler.setFormatter(logging.Formatter("[headroom init] %(message)s")) + handler.setLevel(logging.DEBUG) + logger.addHandler(handler) + logger.setLevel(logging.DEBUG) + logger.propagate = False + setattr(logger, _VERBOSE_HANDLER_ATTR, handler) + + +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: + logger.debug("write json: %s (keys=%s)", path, sorted(payload.keys())) + 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: + logger.debug("ensure claude hooks: %s (profile=%s, port=%s)", path, profile, port) + 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: + logger.debug("ensure copilot hooks: %s (profile=%s)", path, profile) + 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: + logger.debug("ensure codex provider block: %s (port=%s)", path, port) + 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: + logger.debug("ensure codex hooks: %s (profile=%s)", path, profile) + 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} + scope = "windows" if os.name == "nt" else "unix" + logger.debug("apply user env scope=%s keys=%s", scope, sorted(values.keys())) + 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 "chopratejas/headroom" + + +def _run_checked(command: list[str], *, action: str) -> None: + logger.debug("subprocess [%s]: %s", action, _command_string(command)) + result = subprocess.run( + command, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + logger.debug( + "subprocess [%s] exit=%s stdout=%r stderr=%r", + action, + result.returncode, + result.stdout[:200], + result.stderr[:200], + ) + 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(): + logger.debug( + "subprocess [%s] non-zero exit tolerated ('already'/'exists' detected)", action + ) + 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 _probe_init_targets(global_scope: bool) -> list[tuple[str, str | None]]: + """Return ``[(target, which_result)]`` for every in-scope supported target. + + ``which_result`` is the absolute path reported by :func:`shutil.which`, or + ``None`` when the binary is not on PATH. Callers use the list both to + build an auto-detected target list and to produce a diagnostic error + message when nothing was found. + """ + + allowed = _GLOBAL_TARGETS if global_scope else _LOCAL_TARGETS + logger.debug( + "detect_init_targets: global_scope=%s allowed=%s", + global_scope, + sorted(allowed), + ) + probes: list[tuple[str, str | None]] = [] + for target in _SUPPORTED_TARGETS: + if target not in allowed: + continue + path = shutil.which(target) + logger.debug("detect_init_targets: shutil.which(%r) -> %s", target, path or "None") + probes.append((target, path)) + return probes + + +def detect_init_targets(global_scope: bool) -> list[str]: + """Return agent names in scope for which a binary was found on PATH.""" + + return [name for name, path in _probe_init_targets(global_scope) if path] + + +def _format_empty_detection_error(global_scope: bool) -> str: + """Build the error message shown when no in-scope targets were detected. + + Lists every agent that was probed, what ``shutil.which`` returned, and + confirms how to proceed explicitly — including that the ``-g`` / ``--global`` + flag the user tried is still valid. + """ + + probes = _probe_init_targets(global_scope) + scope_flag = "-g" if global_scope else "" + scope_label = "user" if global_scope else "local" + + lines: list[str] = [ + f"No supported {scope_label}-scope agents were found on PATH.", + "", + "Headroom probed the following agents via shutil.which():", + ] + for name, path in probes: + status = f"found at {path}" if path else "not found" + lines.append(f" - {name}: {status}") + + lines.extend( + [ + "", + f"The {scope_flag or '--local (no flag)'} option is still supported; " + "headroom init just needs to know which agent to target.", + "Install the agent you want first, then re-run with an explicit target:", + "", + ] + ) + for name, _path in probes: + flag = " -g" if global_scope else "" + lines.append(f" headroom init{flag} {name}") + + lines.extend( + [ + "", + "Tip: run `headroom init --help` to see all options.", + ] + ) + return "\n".join(lines) + + +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: + logger.debug( + "run_init_targets: targets=%s global_scope=%s port=%s backend=%s memory=%s", + targets, + global_scope, + port, + backend, + memory, + ) + 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, + ) + logger.debug("run_init_targets: using profile=%s", profile) + for target in targets: + logger.debug("run_init_targets: dispatching -> %s", target) + 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.option( + "-v", + "--verbose", + is_flag=True, + help="Emit debug-level diagnostics to stderr (flag values, shutil.which results, " + "file paths touched, subprocess invocations and exit codes).", +) +@click.pass_context +def init( + ctx: click.Context, + global_scope: bool, + port: int, + backend: str, + anyllm_provider: str | None, + region: str | None, + memory: bool, + verbose: bool, +) -> None: + """Install durable Headroom integrations for supported agents.""" + if verbose: + _enable_verbose_logging() + logger.debug( + "init: global_scope=%s port=%s backend=%s anyllm_provider=%s region=%s memory=%s " + "invoked_subcommand=%s", + global_scope, + port, + backend, + anyllm_provider, + region, + memory, + ctx.invoked_subcommand, + ) + 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, + "verbose": verbose, + } + return + + targets = detect_init_targets(global_scope) + if not targets: + logger.debug("init: detect_init_targets returned empty; exiting with guided error") + raise click.ClickException(_format_empty_detection_error(global_scope)) + logger.debug("init: detected targets=%s", targets) + _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/tools.py b/headroom/cli/tools.py index 39e767b8c456998b7bf9d7c692a70923940ce647..1cc0b8673cdf83e45de33967337a54bec1425f2e 100644 --- a/headroom/cli/tools.py +++ b/headroom/cli/tools.py @@ -34,6 +34,10 @@ _PASSTHROUGH_CTX = { } +def _is_windows() -> bool: + return sys.platform.startswith("win") + + def _exec_tool(tool: str, argv: Sequence[str]) -> None: try: path = binaries.resolve(tool) @@ -58,7 +62,7 @@ def _exec_tool(tool: str, argv: Sequence[str]) -> None: # that needs to clean up on shell exit must be handled elsewhere (e.g. # the parent `headroom` process, not these thin passthroughs). cmd = [str(path), *argv] - if os.name == "posix": + if not _is_windows(): os.execv(cmd[0], cmd) # never returns else: completed = subprocess.run(cmd, check=False) diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index 6c27e5f304542061c6db1c009fb7206c61167251..fe8c697cb823f94313d1476f9937dd35d1e2ee2c 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -1,2140 +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