diff --git a/docs/superpowers/specs/2026-08-30-local-distribution-design.md b/docs/superpowers/specs/2026-08-30-local-distribution-design.md new file mode 100644 index 00000000..1487404d --- /dev/null +++ b/docs/superpowers/specs/2026-08-30-local-distribution-design.md @@ -0,0 +1,353 @@ +# Local distribution: get context-guru running on a stranger's Claude Code in one command + +## Problem + +Someone finds the repo, wants to know whether it saves them money on their own Claude +Code sessions, and today has to: install Go 1.26, install a C toolchain, build with the +right tags, run a binary, work out `ANTHROPIC_BASE_URL`, and trust that routing their +coding agent through an unknown local proxy will not break it. Most evaluators stop at +step one. + +Step one turns out to be avoidable. **The C toolchain is not required** — `CGO_ENABLED=0` +produces a fully static binary for all four release targets, and our own quickstart is +telling people otherwise. + +The value we want them to feel first is **KV-cache**, not the offloaders. That decides +almost everything below, because cache work requires being on the wire — so this proposal +is about making *the proxy* trivial to install, not about avoiding it. + +Target: **two commands, no toolchain, no guide, reversible.** + +``` +/plugin marketplace add rossoctl/context-guru +/context-guru:install +``` + +## What the user does + +Nothing is copied into their project, and the skill is not something they have to place. +`/plugin install` writes to `enabledPlugins` in `~/.claude/settings.json` (user scope is the +default and means "available across all projects"), so the plugin and its skills are +installed **once per machine** and are then reachable from any repo. + +``` +1. /plugin marketplace add rossoctl/context-guru # once per machine; prompts to trust the source +2. /plugin install context-guru@context-guru # once per machine; may ask for /reload-plugins +3. /context-guru:install # once per repo (or once, with --global) +``` + +Step 3 is the whole reason a skill exists rather than a shell script. It: + +1. detects the platform and installs the binary — `brew` if present, else the release + tarball (one Bash approval); +2. asks which scope to route (defaults to this project only); +3. reads the target settings file, backs it up, and adds **one** key — + `env.ANTHROPIC_BASE_URL` (one file-edit approval); +4. notices if the user already has an `ANTHROPIC_BASE_URL` and asks what to do rather than + overwriting it; +5. starts the proxy, verifies `/healthz`, and prints the dashboard URL. + +Realistically **three to four approval prompts**, of which steps 1–2 happen once, ever. +Afterwards `/context-guru:status` reads `/stats` and explains the numbers, and +`/context-guru:uninstall` removes the key and stops the proxy. + +What is global and what is not: + +| Thing | Scope | Frequency | +|---|---|---| +| Plugin + its skills | global — user settings | once per machine | +| Binary | global — `brew` / `~/.local/bin` | once per machine | +| **Routing (the `env` block)** | **per-project by default**, `--global` opt-in | **once per repo** | +| Proxy process | per-machine, started on demand | automatic | + +Only the *routing decision* is per-repo, and deliberately so — it is the one with blast +radius. Everything else is install-once. + +## What we ship + +Five pieces. Each is independently useful; the order is the order they unblock each other. + +| # | Piece | Why | +|---|---|---| +| 1 | Pure-Go release binaries + Homebrew tap | Removes the toolchain gate entirely | +| 2 | `--idle-exit` self-terminating proxy | Nothing left running on the machine | +| 3 | `cache` preset | The KV-cache-only pitch, fully lossless | +| 4 | A plugin: install skill + `scripts/` + `SessionStart` hook | One command, and an agent that can make the judgment calls | +| 5 | Gateway conformance fixes | Routing must not break their agent | + +### 1. Pure-Go binaries + +`docs/get-started/quickstart-proxy.md` tells every evaluator to set `CGO_ENABLED=1` and +install a C toolchain. Only one dependency needs it, and it is already behind a build tag: + +| Dependency | cgo? | +|---|---| +| `tiktoken-go/tokenizer` | **No.** `internal/tokens/tokens.go` says it outright: *"o200k_base is embedded in the binary (pure-Go, offline, no CGO)"* | +| `modernc.org/sqlite` (dashboard) | **No** — pure Go by design | +| `tree-sitter/go-tree-sitter` | Yes — the only one. Gated behind `//go:build cg_skeleton`, with `internal/treesitter/stub.go` for `!cg_skeleton` | + +Confirmed by building it (see [Verification](#verification)): `CGO_ENABLED=0` yields a +**fully static single binary** — 30.5 MB stripped, `ldd` reports *"not a dynamic +executable"* — for all four release targets. So releases are a plain `GOOS`/`GOARCH` matrix +in one CI job: no C cross-toolchains, no zig, no libc coupling, ~20 lines of GoReleaser. +30 MB is on the large side (tokenizer tables, the embedded dashboard UI and +`modernc.org/sqlite`) but it is a fine `brew` or `curl` download with no runtime deps. + +**The one thing here worth acting on immediately** is that quickstart paragraph. Its +`CGO_ENABLED=1` instruction is true only for a `cg_skeleton` build, so fixing it is a +one-line change that removes the largest onboarding gate we have — and it depends on nothing +else in this proposal. + +`skeleton` is the only casualty, and it costs nothing here: it is not in `codesmart` (the +default) and not in the cache story at all. Ship pure-Go binaries for everyone; a +`-skeleton` variant can follow per-platform, or not at all initially. + +**Distribution, ranked by friction:** + +1. **Homebrew tap** — `brew install rossoctl/tap/context-guru`. One command, **no Gatekeeper + dialog**, plus updates and clean uninstall. The primary path for macOS and Linux. +2. `go install` — one command, but puts the toolchain back. +3. `curl … | sh` — one command, but macOS quarantines an unsigned download ("cannot be + verified"). Fixable with `xattr -d com.apple.quarantine` in the installer, though + `curl | sh` reads badly to this repo's audience. Notarization needs a paid Apple account. +4. Plugin `bin/` — committing per-platform binaries to git is ugly, and `bin/` is barred + from claude.ai org distribution. + +Decision: **tap first, `curl | sh` as fallback, `go install` documented for Go users.** + +### 2. `--idle-exit` — the proxy cleans itself up + +Lifecycle is tied to sessions (piece 4 starts it), so nothing should outlive use. The +machinery already exists in `cmd/context-guru-proxy/main.go`: SIGINT/SIGTERM handling, +`srv.Shutdown`, and `armShutdown` to release the dashboard's SSE connections. An idle +watchdog just triggers the same path — atomic last-request timestamp, ticker, exit 0. + +Three constraints, all load-bearing: + +- **Off by default.** A gateway or eval-containers deployment must never self-terminate. + `--idle-exit=24h` is opt-in and set only by the installer. +- **The threshold has a floor: `store.ttl_seconds`.** Exit wipes the in-memory Store — + rewind stashes, frozen decisions, `cg:len:`. A frozen decision dying mid-session is the + 11.5× cache-write bug that `FrozenLost` exists to detect. The default TTL is 10000s + (≈2.8h), so 24h is safely past it and nothing live is lost — but a *short* threshold (10 + or 30 minutes) would be actively destructive. **Enforce the floor in config validation, + not in a doc comment.** +- **The keepalive inverts the meaning of idle.** If pings are the point, the proxy has to be + alive precisely when there is no client traffic — idle is when it works (X=280s → ping). A + naive watchdog kills the feature it ships. The idle clock must also reset on keepalive + activity, and exit only when no session has a pending ping schedule. 24h ≫ the keepalive + window (K=2 ≈ 10 min) so they coexist, but that has to be explicit rather than lucky. + +Self-kill alone is a footgun; self-kill **plus** `SessionStart` resurrection is coherent. +They ship together or not at all. Longer term a SQLite-backed store makes self-kill free at +any threshold — `store` already has the interface seam for it. + +### 3. A `cache` preset + +`pipeline: [cachesplit]` and nothing else. This is the funnel's default. + +Why it is the right first taste, and not a watered-down one: + +- **Fully lossless.** No content dropped, no `<>` markers, no `context_guru_expand` + tool injected, no LLM calls. The loudest objection to a context proxy — *you are editing + my agent's context* — does not apply, and that is verifiable by reading one config line. +- **It is the best-evidenced component we have**: −34.1% cost, 0% → 96.7% cache hit in an + isolated A/B, and it already ships in every preset. +- **The win is visible in the dashboard's four billed token tiers**, which come from the + provider's usage block. + +`safe` (`format` → `cachesplit`) is close but still rewrites JSON; `cache` makes the claim +exact. Note `cachesplit` and `cacheinject` are both no-ops on implicit prefix-cache backends +(vLLM, llm-d) — `apply/prefixsplit.go` says so — so the funnel's pitch is Anthropic-family +specific and the docs should say that plainly. + +### 4. The plugin: skill + `scripts/` + hook + +A plugin **cannot** set `ANTHROPIC_BASE_URL` — plugin `settings.json` accepts only `agent` +and `subagentStatusLine`. So the plugin is not the transport; it is the **installer and +operator surface**, and it closes the loop by having Claude do the file edit. + +``` +context-guru-plugin/ + .claude-plugin/plugin.json # userConfig: preset, port, scope + skills/install/SKILL.md # /context-guru:install — drives scripts/, makes the judgment calls + skills/status/SKILL.md # /context-guru:status — reads /stats, explains the numbers + skills/uninstall/SKILL.md # /context-guru:uninstall — removes the env key, stops the proxy + scripts/install.sh # brew → curl fallback; idempotent; prints machine-readable result + scripts/settings.py # merge/remove ONE key in a settings.json, with backup + scripts/start-proxy.sh # healthz probe, start only if absent, fixed port + hooks/hooks.json # SessionStart → start-proxy.sh +``` + +**Why a skill rather than a shell script alone.** A skill does not reduce permission +prompts, it *concentrates* them — an eight-step guide becomes eight prompts unless the skill +runs one command. The real reason for the skill is the part a script does badly: + +`init` must merge into an existing `~/.claude/settings.json` that already holds the user's +theme, model, permission rules, and possibly their own `ANTHROPIC_BASE_URL`. That is +judgment work — read it, back it up, add exactly one key, detect a conflicting base URL +already present, choose a scope, verify, and report. An agent handles that; `jq` gymnastics +against an unknown file does not. Every maintainer's own settings file is the proof: at +least one of ours already sets a base URL plus a benchmark override that must survive. + +So: **`scripts/` for the deterministic steps, the skill for the judgment and the verify.** + +**Scope choice, which the skill must ask about.** Precedence is managed → `--settings` → +`.claude/settings.local.json` → `.claude/settings.json` → `~/.claude/settings.json`, so user +scope is the *lowest*. + +| Write target | Reaches | Blast radius if the proxy is down | +|---|---|---| +| `.claude/settings.local.json` | you, this repo, gitignored | one repo — **the install default** | +| `.claude/settings.json` | everyone who clones the repo | one repo, whole team — team adoption | +| `~/.claude/settings.json` | you, every project on the machine | **every Claude Code session you have** | + +A global base URL pointing at `localhost` means a dead proxy breaks Claude Code everywhere, +including repos the evaluator never meant to experiment in. For a trial aimed at strangers +that blast radius is the main risk in this proposal — bigger than the build. Hence: default +to project-local, `--global` as an explicit opt-in, and `--idle-exit` paired with the +`SessionStart` hook so "is it running?" stops being the user's problem. + +`env` blocks merge **per key** across scopes, so a user-scope install is not clobbered by a +repo that ships its own `env` block, and `--global` needs no per-repo caveat. Verified; +see [Verification](#verification). That leaves `init` one conflict to reason about rather +than two: an `ANTHROPIC_BASE_URL` the user already set themselves. + +**Why `SessionStart` and not a launchd/systemd service.** Session-scoped lifetime leaves +nothing running on the machine and needs no privileged install. Mechanics check out: +`SessionStart` supports `type: "command"`, hooks are awaited unless `async: true` is set, and +the default timeout is 600s — so a hook that starts the proxy and polls `/healthz` before +returning closes the race with the first API request. Five details: + +- **The hook must self-gate on `ANTHROPIC_BASE_URL`.** The plugin is installed at user scope, + so its hooks run in **every** project — including the ones the user never routed. Starting a + proxy there is pure waste. The fix needs no extra configuration: settings `env` values are + written into the process environment and hook processes inherit it, so the hook reads + `$ANTHROPIC_BASE_URL` and exits immediately unless it names our port. That makes the hook + run in exactly the projects where routing is configured, with no per-project enablement and + no second copy of the port to keep in sync. It also degrades correctly: if the user removes + the env key by hand, the hook stops firing on its own. +- **Never set `async: true`** on this hook; it reintroduces the race. +- **`SessionStart` also fires on `clear`, `compact`, `resume` and `fork`**, not just + `startup`. The starter must be idempotent — probe `/healthz`, start only if absent. + Compaction alone re-fires it repeatedly in a long session. +- **Fixed port.** The env block and the hook have to agree, and you cannot negotiate a + dynamic port after the env value is already written. Configurable at install time, written + to both places. Not 4000 — that collides with litellm. +- **Trust gate.** A hook in a committed `.claude/settings.json` that launches a binary + prompts on clone. Correct behaviour, but it means "clone and go" is really "clone, approve, + go", and the README should say so rather than surprise people. + +Note the plugin also gives us `SessionEnd` and `PreCompact`/`PostCompact` as explicit +signals, which is strictly better than `proxy/agentcompaction.go` inferring compaction from a +string match against a specific Claude Code build. Out of scope here; worth recording. + +### 5. Gateway conformance + +This funnel puts us on the wire, so a broken trial is a lost adopter. Checked against the +gateway protocol reference; each needs a test, and #1 and #2 are the ones that can make the +demo look *negative*: + +1. **Do not buffer SSE.** *"A gateway that buffers complete responses before relaying them + stalls the client"*, and Claude Code aborts a stream silent for 300s. We have + `sse_buffered` / `sse_buffered_pct` counters, so we do buffer some responses to inspect + for an expand call. Under the `cache` preset nothing injects the expand tool, so this + should be inert — verify it against a real session rather than assume. +2. **A rejected `cache_control` disables prompt caching for the rest of the conversation.** + Claude Code retries and turns the capability off. So a breakpoint-budget mistake is not an + error the user sees — it silently switches off the thing we are selling. This is the + strongest argument for shipping `cache` (no `cacheinject`) rather than a placement preset. +3. **The attribution block is stripped positionally.** Claude Code prepends it as the first + `system` block and the API strips it only if the array arrives unchanged. `cachesplit` + reshapes that array. Either prove the interaction is safe (our split has a + `minSplitTokens` floor of 1024, so it should never touch the small attribution block) or + ship `CLAUDE_CODE_ATTRIBUTION_HEADER=0` in the install. +4. **Forward error bodies byte-unmodified.** Claude Code's capability-rejection retry matches + on the upstream's error *wording*; wrapping errors breaks the recovery path. +5. **Serve `/v1/messages/count_tokens`.** Absent, Claude Code counts context by issuing + inference requests. Cheap to add. + +Confirmed already correct: `copyHeaders` forwards `anthropic-beta` (which carries the OAuth +capability — stripping it is a `401`), and auth headers are dropped only when a real API key +is configured. + +## Subscription auth works, and it is the headline + +Setting `ANTHROPIC_BASE_URL` **without** a credential variable keeps the user's claude.ai +login active: their Pro/Max usage limits and billing continue to apply. Combined with the +above, an evaluator can trial context-guru **with no API key at all.** + +That is the single most important adoption fact about this project and it appears nowhere in +our docs. One caveat to state honestly in the docs: on subscription billing the saving lands +in usage limits rather than dollars, so `/stats` cost figures are list-price estimates that +do not match a subscriber's bill. + +## Non-goals + +- **Not a replacement for the proxy.** This is packaging for the proxy, and the proxy stays + the only place cache work happens. +- **No offloaders in the funnel default.** They are a separate story (see the plugin-transport + analysis, PR #129) and lossy-by-design, which is the wrong first impression for a cache pitch. +- **No measurement-only mode.** A plugin that merely reports what cache expiry costs was + considered and rejected: without the ability to ping, it diagnoses a problem it cannot fix. +- **Not the DAM integration.** DAM is harness-plural with its own gateway; a Claude Code + plugin covers one harness. Separate proposal. + +## Open questions for reviewers + +All judgment calls — nothing below is an unknown that could invalidate the plan. + +1. **Default scope: project-local or global?** Local is safer (a dead proxy costs one repo), + global is the better demo. The proposal picks local; happy to be overruled. +2. **Idle-exit default: 24h?** And should the floor be `max(2 × store.ttl_seconds, 1h)`? +3. **Homebrew tap: `rossoctl/homebrew-tap` as a new repo, and who owns release signing?** +4. **Does the 30 MB artifact bother anyone?** It is mostly tokenizer tables, the embedded + dashboard UI and `modernc.org/sqlite`. A `-slim` build without the dashboard is possible if + it matters, but it costs the demo its best surface. +5. **Ship `skeleton` at all in v1?** It is the only thing needing cgo, so a `-skeleton` + variant means per-platform CI for one component that is not in `codesmart` and not in the + cache story. Proposal: omit it from the first release and document the source build. + +## Staging + +- **Stage 1** — GoReleaser + tap + `cache` preset + fix the CGO claim in the quickstart. The + funnel already works here for anyone willing to run two commands by hand. +- **Stage 2** — `--idle-exit` + the plugin (skill, scripts, `SessionStart` hook). +- **Stage 3** — conformance items 1–5 with tests. + +Stage 1 is shippable alone and is most of the adoption win; stages 2–3 make it pleasant and +safe. The smallest useful change in the whole proposal is the one-line quickstart fix. + +--- + +## Appendix: Verification + +Two assumptions the plan rests on were checked rather than trusted. Both came back the way +the proposal assumed, so neither changes a decision above — they are recorded so a reviewer +does not have to take the claims on faith, and the scripts are committed so they can be +re-run. + +**The binary needs no C toolchain** — `scripts/gate-a-purego.sh`, run 2026-08-30 against +`go 1.26.4` in a `golang:1.26` container (so it needs no local Go install): + +| Check | Result | +|---|---| +| default tags, `CGO_ENABLED=0` | PASS | +| `-tags cg_skeleton`, `CGO_ENABLED=0` | fails, as intended — `build constraints exclude all Go files in …/go-sitter-forest/typescript`, the cgo-disabled signature. Confirms tree-sitter is the only C dependency | +| `GOOS`/`GOARCH` × {linux,darwin} × {amd64,arm64} | PASS, all four | +| artifact | 30.5 MB stripped (`-ldflags="-s -w"`), 38.3 MB unstripped, `ldd` → *"not a dynamic executable"* | + +The first run of the `cg_skeleton` check failed on `proxy.golang.org` timeouts rather than on +cgo, which would have been recorded as a confirmation it was not. The script now fetches the +grammar modules first and reports `INCONCLUSIVE` if it cannot, so a network error cannot be +mistaken for the error being tested for. + +**`env` blocks merge per key** — `scripts/gate-b-envmerge.sh`. It relocates the config dir +via `CLAUDE_CONFIG_DIR` so it never touches a real `~/.claude`, and reads the result from a +`SessionStart` hook that dumps the environment it was handed rather than asking a model to +report it. With `CG_USER`+`CG_BOTH` in user scope and `CG_BOTH`+`CG_PROJ` in project scope, +the hook saw `CG_BOTH=project`, `CG_PROJ=project`, `CG_USER=user` — the higher-precedence +file won only the key it sets, and the user-scope object survived. + +Still unverified, and called out where it matters above: the five gateway conformance items +in piece 5. diff --git a/scripts/gate-a-purego.sh b/scripts/gate-a-purego.sh new file mode 100755 index 00000000..5fbf1e50 --- /dev/null +++ b/scripts/gate-a-purego.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Gate A: can context-guru ship as a static binary with no C toolchain? +# +# Runs in a golang container, so it needs no local Go install. A named volume holds the +# module cache so the matrix downloads once rather than once per target. +# +# 1. default tags, CGO_ENABLED=0 -> must SUCCEED (the binary we want to ship) +# 2. -tags cg_skeleton, CGO_ENABLED=0 -> must FAIL (tree-sitter is the only cgo dep) +# 3. GOOS/GOARCH matrix, CGO_ENABLED=0 -> must SUCCEED for all four release targets +# 4. artifact size and linkage +# +# Results as of 2026-08-30 (go 1.26.4, module github.com/rossoctl/context-guru): +# 1 PASS · 2 PASS (fails with "build constraints exclude all Go files", the +# cgo-disabled signature) · 3 PASS on all four · 4 30.5 MB stripped, "not a dynamic +# executable". +# +# Note: `go mod download all` is flaky against proxy.golang.org from here and may report +# failures while the builds still succeed — `go build` fetches only what it needs. +set -uo pipefail +cd "$(dirname "$0")/.." + +IMG="${IMG:-golang:1.26}" +VOL="${VOL:-cg-gomodcache}" +LOG="${LOG:-/tmp/gate-a.log}" +: >"$LOG" + +run() { docker run --rm -v "$PWD":/src -v "$VOL":/go/pkg/mod -w /src \ + -e GOFLAGS=-buildvcs=false "$@"; } + +result() { # name, expected(ok|fail), exit code + local name="$1" want="$2" code="$3" verdict + if [[ "$want" == ok ]]; then + [[ "$code" -eq 0 ]] && verdict="PASS" || verdict="FAIL" + else + [[ "$code" -ne 0 ]] && verdict="PASS (failed as expected)" || verdict="UNEXPECTED SUCCESS" + fi + printf '%-38s exit=%-3s %s\n' "$name" "$code" "$verdict" | tee -a "$LOG" +} + +echo "== prewarming module cache (best effort, up to 3 attempts) ==" | tee -a "$LOG" +for i in 1 2 3; do + run -e CGO_ENABLED=0 "$IMG" go mod download all >>"$LOG" 2>&1 && { echo " ok on attempt $i" | tee -a "$LOG"; break; } + echo " attempt $i failed (builds may still succeed)" | tee -a "$LOG" +done + +echo | tee -a "$LOG" +echo "== 1. default tags, CGO_ENABLED=0 (want SUCCESS) ==" | tee -a "$LOG" +run -e CGO_ENABLED=0 "$IMG" go build -o /tmp/cg ./cmd/context-guru-proxy >>"$LOG" 2>&1 +result "default tags / CGO=0" ok $? + +echo | tee -a "$LOG" +echo "== 2. -tags cg_skeleton, CGO_ENABLED=0 (want FAILURE) ==" | tee -a "$LOG" +echo "-- fetching tree-sitter grammars first, so a network error cannot be mistaken" | tee -a "$LOG" +echo "-- for the cgo error we are actually testing for" | tee -a "$LOG" +grammars=no +for i in 1 2 3 4 5; do + run -e CGO_ENABLED=0 "$IMG" go mod download github.com/alexaandru/go-sitter-forest/c >/dev/null 2>&1 \ + && { grammars=yes; echo " fetched on attempt $i" | tee -a "$LOG"; break; } + echo " attempt $i failed" | tee -a "$LOG" +done +if [[ "$grammars" == yes ]]; then + run -e CGO_ENABLED=0 "$IMG" go build -tags cg_skeleton -o /tmp/cg-skel ./cmd/context-guru-proxy >>"$LOG" 2>&1 + result "cg_skeleton / CGO=0" fail $? + grep -m1 "build constraints exclude" "$LOG" >/dev/null \ + && echo " reason: \"build constraints exclude all Go files\" = cgo disabled" | tee -a "$LOG" +else + echo "cg_skeleton / CGO=0 INCONCLUSIVE (could not fetch grammars)" | tee -a "$LOG" +fi + +echo | tee -a "$LOG" +echo "== 3. cross-compile matrix, CGO_ENABLED=0 ==" | tee -a "$LOG" +for t in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do + os="${t%/*}"; arch="${t#*/}" + run -e CGO_ENABLED=0 -e GOOS="$os" -e GOARCH="$arch" "$IMG" \ + go build -o "/tmp/cg-$os-$arch" ./cmd/context-guru-proxy >>"$LOG" 2>&1 + result "$t" ok $? +done + +echo | tee -a "$LOG" +echo "== 4. artifact size and linkage (linux/amd64) ==" | tee -a "$LOG" +# Inspected inside the container: Rancher Desktop does not share /tmp with the host. +run -e CGO_ENABLED=0 -e GOOS=linux -e GOARCH=amd64 "$IMG" sh -c ' + set -e + go build -ldflags="-s -w" -o /tmp/cg ./cmd/context-guru-proxy + ls -l /tmp/cg | awk "{printf \"stripped: %.1f MB\n\", \$5/1048576}" + go build -o /tmp/cg-plain ./cmd/context-guru-proxy + ls -l /tmp/cg-plain | awk "{printf \"unstripped: %.1f MB\n\", \$5/1048576}" + printf "linkage: "; ldd /tmp/cg 2>&1 | head -1 +' 2>&1 | tee -a "$LOG" + +echo | tee -a "$LOG" +echo "full log: $LOG" diff --git a/scripts/gate-b-envmerge.sh b/scripts/gate-b-envmerge.sh new file mode 100755 index 00000000..ac302595 --- /dev/null +++ b/scripts/gate-b-envmerge.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Gate B: does an `env` block MERGE per-key across settings files, or does the +# highest-precedence file replace the whole object? +# +# Why it matters for distribution: `context-guru init` writes ANTHROPIC_BASE_URL into a +# settings file. If a lower-precedence file's `env` object is replaced wholesale by a +# higher one, then a user-scope install (~/.claude/settings.json, the LOWEST precedence) +# silently stops working in any repo that ships its own `env` block — i.e. exactly the +# most-configured repos, and with no error. +# +# Method: no model call, no network. A SessionStart hook dumps the environment it was +# given to a file. Claude Code runs SessionStart hooks before the first request, so the +# session is killed immediately afterwards and the dump is still written. +# +# user scope (CLAUDE_CONFIG_DIR) : CG_USER=user CG_BOTH=user +# project scope (.claude/…json) : CG_BOTH=project CG_PROJ=project +# +# MERGE => CG_USER=user, CG_BOTH=project, CG_PROJ=project (union, project wins the clash) +# REPLACE => CG_USER unset, CG_BOTH=project, CG_PROJ=project (user object discarded) +# +# The real user's ~/.claude is never touched: CLAUDE_CONFIG_DIR relocates it. +set -uo pipefail + +LAB="$(mktemp -d /tmp/cg-envmerge.XXXXXX)" +CFG="$LAB/cfgdir" +PROJ="$LAB/proj" +DUMP="$LAB/env.txt" +mkdir -p "$CFG" "$PROJ/.claude" + +cat >"$CFG/settings.json" < $DUMP" } + ] + } + ] + } +} +JSON + +cat >"$PROJ/.claude/settings.json" <"$LAB/claude.out" 2>&1 +echo "claude exit=$? (output: $LAB/claude.out)" +echo + +if [[ ! -s "$DUMP" ]]; then + echo "RESULT: INCONCLUSIVE — hook produced no dump." + echo " Check $LAB/claude.out; the hook may not have run (trust gate?) or CG_ vars" + echo " were not exported to hook processes." + exit 2 +fi + +echo "=== environment the hook saw ===" +cat "$DUMP" +echo + +got_user=$(grep -c '^CG_USER=user$' "$DUMP" || true) +got_both=$(grep -c '^CG_BOTH=project$' "$DUMP" || true) +got_proj=$(grep -c '^CG_PROJ=project$' "$DUMP" || true) + +echo "=== verdict ===" +if [[ "$got_user" -eq 1 && "$got_both" -eq 1 && "$got_proj" -eq 1 ]]; then + echo "MERGE — env objects union across scopes; the higher-precedence file wins only" + echo " the keys it actually sets. A user-scope install survives a project env block." +elif [[ "$got_user" -eq 0 && "$got_both" -eq 1 && "$got_proj" -eq 1 ]]; then + echo "REPLACE — the project env object replaced the user one wholesale (CG_USER lost)." + echo " A user-scope install silently dies in any repo shipping its own env block." + echo " => init must write project-local scope, or verify per-repo." +else + echo "UNEXPECTED — CG_USER=$got_user CG_BOTH=$got_both CG_PROJ=$got_proj" + echo " Inspect the dump above before drawing a conclusion." +fi