Root cause of the 2026-08-11 stream leak, reproduced deterministically: a
2025-era JSON-RPC batch carrying a request plus notifications/cancelled
for that same request never terminates. Per spec a cancelled request gets
no response, but the SDK's legacy stateless transport only closes the
POST's SSE stream once every request in it has been answered — so the
exchange hangs. The 15s keepalive heartbeats then kept the hung stream
"active" forever: no proxy idle timeout could fire, and only the
gateway's 1200s hard cap reaped it. v1 hung on the same batch but sent
no keepalives, so proxy idle timeouts self-healed it within the hour —
which is why the leak only became an outage with v2.
Fix: keepAliveMs: 0. No legitimate exchange here needs a heartbeat (the
tools are millisecond vector queries, p100 ~28s, zero requests over 30s
in 62.7M/day), so the only streams keepalives were keeping alive were
dead ones. Hung exchanges now go silent and the gateway reaps them at
streamIdleTimeout (300s, deployed in context7parser#655) instead of
accumulating for 1200s. This covers the whole class of silent hangs, not
just the cancellation shape.
Validated:
- cancel-batch hang emits 0 bytes over 35s (was: keepalive every 15s);
gateway idle-reap of silent streams was proven separately on a local
Envoy Gateway v1.8.1 (silent stream cut at the idle timeout,
heartbeating stream never)
- tools/call, [req,req] batches, modern-era requests all unchanged
- typecheck, eslint, prettier clean
The SDK accounting bug (cancelled requests should count as settled for
stream close) remains to be filed upstream.
Both fetch calls in packages/mcp/src/lib/api.ts ran without a signal, so a
stalled backend call rode undici's ~300s implicit default before failing.
An explicit 60s AbortSignal.timeout() makes the ceiling deliberate: the
call fails fast with a logged, proper JSON-RPC error result instead of
hanging for five minutes on an implicit dependency default.
60s is generous for these vector queries: p99.9 is ~3.2s and no request
exceeded 30s across a full day of production traffic. It also keeps the
longest legitimate silent window on an SSE exchange well under the
gateway's 300s streamIdleTimeout, which pairs with disabling SSE
keepalives (the stream-leak fix): legit exchanges stay 5x clear of the
idle reaper while hung ones get reaped.
Validated against a backend that accepts connections and never responds:
before, tools/call stalled ~300s before erroring; after, it returns a
JSON-RPC error result at the timeout and logs it. Happy path unaffected;
typecheck, eslint, prettier clean.
Forcing responseMode: "sse" put every MCP response on an SSE stream. Those
streams were not being released: concurrent upstream streams went from ~10
before the v4.0.0 deploy on 08/07 to over 5000 by 08/11, exhausting Envoy's
1024-connection pool and returning 503 "reset reason: overflow" on
mcp.context7.com, including /ping and /mcp/oauth.
Traffic and latency were unchanged across that window (~800 req/s, ~10ms
mean), so this was not load. Little's Law puts healthy concurrency at
834 req/s x 13.4ms = 11 streams, which is exactly what was observed before
v4.0.0.
The SDK default "auto" answers with a single JSON body and upgrades to SSE
only when a handler emits a related message before its result. No tool here
emits progress, so every response becomes JSON.
Verified locally against a running server:
- modern (2026-07-28) requests now return content-type: application/json
- tools/list, resolve-library-id and query-docs all dispatch correctly
- typecheck, eslint and prettier clean
Known limitation: the 2025-era legacy fallback is constructed as
createLegacyStatelessFallback(factory, reportError, options.keepAliveMs) and
never receives responseMode, so legacy requests still stream over SSE. This
change only affects modern-protocol clients.
* fix(cli): refresh expired tokens for documentation commands
- Reuse getValidAccessToken in library and docs commands
- Avoid anonymous fallback when OAuth access tokens expire
* fix(cli): refresh expired tokens in skills suggest and generate
Route the remaining hand-rolled loadTokens/isTokenExpired checks through
getValidAccessToken so an expired token refreshes instead of silently
falling back to anonymous (skills suggest) or forcing a full re-login
(generate). Return undefined instead of null to match the optional
accessToken parameter on the API surface.
* fix(cli): preserve refresh_token and pin the auth wiring
RFC 6749 §6 permits a refresh response that omits refresh_token, in which
case the client keeps the one it holds. getValidAccessToken wrote the
response verbatim, dropping the stored token and silently logging the user
out at the next expiry. This PR widened that path from 2 commands to 6, so
fix it here.
Add a wiring test asserting each command passes a refreshed token to its
API call, and an eslint rule blocking loadTokens/isTokenExpired imports in
src/commands so the inline check cannot come back.
---------
Co-authored-by: Fahreddin Özcan <ozcanfahrettinn@gmail.com>
* docs: remove broken Smithery badge from READMEs
The smithery.ai/badge endpoint returns HTTP 500 with an empty body for
every server, not just Context7. Smithery also removed all badge
documentation from their docs, so the feature looks retired.
The Smithery listing itself is still live and the install section keeps
linking to it, which satisfies Smithery's backlink verification.
* docs: remove broken Star History chart from READMEs
GitHub restricted the stargazers API to repo admins and collaborators on
2026-06-30, so star-history.com can no longer build the chart. The SVG
still returns 200 but renders "GitHub restricted access to star data".
Restoring it would mean embedding a GitHub access token in the chart URL
and handing it to a third party, which we do not want to do.
* fix(cli): write the API key as an Authorization header
Codex resolves a server's auth mode by checking only for
`bearer_token_env_var` or a header literally named `Authorization`
(`auth_status_before_discovery` in codex-rs/rmcp-client/src/auth_status.rs,
mirrored in `create_transport` in rmcp_client.rs). The custom
`CONTEXT7_API_KEY` header matched neither, so Codex fell through to any OAuth
credential stored for the same server name and URL and refreshed it during
startup. A dead refresh token then failed the server with `invalid_grant`
before the API key was ever sent, and re-running setup could not recover it
because setup writes config.toml and never touches the credential store.
The hosted endpoint accepts both header forms, so existing configs keep
working.
Two places keep the legacy header deliberately: the plugin .mcp.json files
default to `${CONTEXT7_API_KEY:-}`, and the server rejects `Bearer` with an
empty token while treating a missing header as anonymous; and `env` blocks in
stdio configs, where the name is an environment variable rather than a header.
* fix(plugins): send the API key via the Authorization header
The Claude and Copilot plugin configs default to `${CONTEXT7_API_KEY:-}`, and
both plugins document that an unset key still works over the anonymous tier.
The Bearer form cannot express that: the server rejects `Bearer` with an empty
token while treating an empty or missing Authorization header as anonymous.
The raw-key form satisfies both states. It is genuinely parsed rather than
ignored, verified by an invalid raw key being rejected, so a set key still
authenticates while an unset one falls back to anonymous as documented.
Once the server treats an empty-token Bearer as no header, these can move to
the `Bearer <key>` form used everywhere else.
* refactor(cli): narrow the Codex OAuth probe and trim its surface
Only `oauth` proves a stored credential exists. `not_logged_in` also covers
"no credential, server merely advertises OAuth", which is the normal state for
anyone who never logged in, so treating it as stale told most users their
config held a credential it did not.
Collapse the module to the two functions the call site needs, derive nothing
from a hand-maintained status list, and skip the subprocess entirely when the
server is not already in Codex's config. Drop the probe timeout to 1.5s and
kill with SIGKILL so it is a real ceiling rather than an intent, since the
result is only an advisory hint.
Lock the plugin manifests' raw-key form behind a test, so normalizing them to
`Bearer` for consistency with the CLI fails loudly instead of silently
breaking anonymous access.
* refactor(cli): drop the Codex OAuth cleanup note
The note existed because re-running setup could not rescue a stuck user. The
Authorization header change in this same branch makes it rescue them: Codex
never reads the stored credential once that header is present, so the
credential is inert and the hint only offered cosmetic cleanup.
Removing it drops a subprocess spawn from a user-facing path and a dependency
on the shape of `codex mcp get --json`, an external contract this repo does not
pin. The reason the header name matters moves to `withHeaders`, where the
decision is encoded.
* focus Context7 documentation queries
* narrow documentation query prompt changes
* remove focused from query prompts
* use lookup wording in query prompts
* distinguish documentation lookup from task
* allow live Pi test more time
* add prompt guidance changeset
The skill download step in `ctx7 setup` hits the git tree API on
api.github.com to enumerate a skill's files. When that host is blocked
or unreachable (while the docs host is fine), the fetch throws and setup
reports "Skill failed / fetch failed" (#2936).
Fall back to fetching the single SKILL.md directly from
raw.githubusercontent.com — the URL the docs API already resolves — so
single-file skills install even when api.github.com is not reachable.
Node 26 bundles undici 8, whose built-in fetch reads a global-dispatcher symbol
(Symbol.for('undici.globalDispatcher.2')) that the bundled undici 6
setGlobalDispatcher never wrote. The ProxyAgent and custom-CA Agent in api.ts
were therefore ignored, so HTTPS_PROXY and NODE_EXTRA_CA_CERTS were silently
dropped and requests failed with ENOTFOUND behind CONNECT proxies (#2935).
undici 7 writes both the legacy and current symbols, restoring proxy and CA
support across Node 20-26. It requires Node >=20.18.1, so Node 18 (EOL) is no
longer supported; the engines field and README are updated accordingly.
Fixes#2935
* fix(cli): avoid shell for GitHub auth token
* fix(cli): document the shell-free constraint and harden gh token tests
Record why `gh auth token` must stay shell-free so the .cmd/.bat shim gap
is not "fixed" by re-adding `shell`, which would restore the cmd.exe
process that #2918 is about.
- reset mock implementations between tests so they stop leaking
- assert listSkillsFromGitHub's result; the tests passed green without it
- cover the GH_TOKEN fallback, which was previously untested
- reword the changeset: execSync spawned a shell on every platform, and
on Windows that shell was load-bearing rather than "unnecessary"
---------
Co-authored-by: Fahreddin Özcan <ozcanfahrettinn@gmail.com>
* fix(mcp): skip loopback and IPv6 private IPs in getClientIp
Extract getClientIp into lib/client-ip.ts and extend the private/local
IP filter to cover 127.0.0.0/8, 169.254.0.0/16, ::1, fe80::/10, and
fc00::/7 when walking X-Forwarded-For. Proxies that prepend loopback or
health-check addresses no longer pollute mcp-client-ip analytics.
Fixes#2874
* fix(mcp): tighten private IP detection and add changeset
Anchor the fe80::/10 and fc00::/7 regexes to full 4-digit first hextets
so abbreviated hextets like fe8::1 or fc::1 are no longer misclassified
as private. Match IPv6 loopback in any textual form (0::1,
0:0:0:0:0:0:0:1), add CGNAT (100.64.0.0/10) to the skip list, and add a
patch changeset.
---------
Co-authored-by: syf2211 <syf2211@users.noreply.github.com>
Co-authored-by: Fahreddin Özcan <ozcanfahrettinn@gmail.com>
Fixes#2860
- Use npx ctx7@latest as the canonical CLI invocation in find-docs SKILL.md
- Add official library naming guidance matching rules/context7-cli.md
- De-emphasize global npm install as the primary workflow
- Add regression tests to keep skill and rule guidance aligned
Co-authored-by: syf2211 <syf2211@users.noreply.github.com>
* feat(mcp): switch anonymous sign-in nudge to elicitation
Replace the in-result markdown nudge with an MCP `elicitation/create`
form request. The previous text-injection approach instructed the
assistant to relay the message to the user, which some agents flagged
as prompt injection. Elicitations are delivered out-of-band to the
client UI, bypassing that surface entirely.
- `maybeElicitAuthSignIn` fires after each tool response when the
backend has set `ctx.shouldPrompt` (via `X-Context7-Auth-Prompt: 1`)
and the caller is anonymous.
- Gated on the client advertising the `elicitation` capability;
no-op otherwise.
- Includes a "Don't show this again" checkbox; opting out suppresses
further nudges for the lifetime of the MCP process, keyed per
session id / client IP.
- Fire-and-forget: the elicitation never blocks or fails the
surrounding tool response.
* feat(mcp): two-option choice in sign-in elicitation
Replace the "Don't show this again" checkbox with a single-select
radio between "I'll run the command to sign in" and "Continue
anonymously with smaller limits". The radio makes the user's intent
explicit and softens the protocol-fixed Accept/Decline labels —
Accept now just submits the choice.
Picking "Continue anonymously" (or declining/cancelling outright)
suppresses further nudges for the lifetime of the MCP process.
The command itself stays in the dialog message for the user to
copy; the server does not attempt to drive the client to execute it.
* fix(mcp): use plain enum schema for choice radio
Switch the elicitation's choice field from `oneOf` with separate
`const`/`title` entries to the simpler `enum: [...]` shape. Cursor's
elicitation UI does not render the `oneOf`-with-titles pattern
correctly — it falls back to a plain text input with the const string
as the default value. The flat enum form is rendered as a proper
dropdown / radio across the clients we tested.
The user-facing strings are now also the enum const values, so the
elicitation response surfaces the chosen label directly. Suppression
logic compares against the same string constants.
* refactor(mcp): drop in-memory suppression, let backend own prompt frequency
The MCP server no longer keeps a per-session suppression set. It fires the
elicitation whenever X-Context7-Auth-Prompt is present; the backend now emits
that header at most once per MCP session.
* chore(deps): bump dependencies (combined dependabot updates)
Combines the safe dependabot dependency bumps into a single change:
- @modelcontextprotocol/sdk 1.25.2 -> 1.29.0 (mcp)
- undici 6.26.0 -> 8.3.0 (mcp)
- zod 4.3.5 -> 4.4.3 (mcp, tools-ai-sdk)
- commander 13.1.0 -> 15.0.0 (cli)
- ora 9.0.0 -> 9.4.0 (cli)
- dotenv 17.2.3 -> 17.4.2 (sdk, tools-ai-sdk, pi)
- @earendil-works/pi-coding-agent 0.75.5 -> 0.78.0 (pi)
eslint 9 -> 10 (#2703) is excluded: it is incompatible with the
pinned typescript-eslint v8 and breaks lint.
Verified: build, typecheck, lint, and tests pass.
* chore: add changesets for runtime dependency bumps
* fix(deps): pin undici to 7.x for Node 20 compatibility
undici 8 requires Node >=22.19.0 (it calls worker_threads.markAsUncloneable
unconditionally at module load), but CI and the release pipeline run Node 20,
which crashed the mcp test suite with 'markAsUncloneable is not a function'.
undici 7.27.0 guards that call and supports Node >=20.18.1.
* fix(sdk): avoid raw SyntaxError on non-JSON error responses
Wrap res.json() in the error path with .catch(() => ({})) so non-JSON
error bodies (HTML 502s, plain-text 429s, Cloudflare challenge pages)
fall through to res.statusText and always surface as a typed
Context7Error instead of a native SyntaxError.
Closes#1964
* chore: add changeset for sdk non-JSON error fix
* test(cli): pin home dir via HOME env instead of mocking os builtin
The storage-paths and auth-utils tests mocked the `os` module to fix
homedir, but that mock resolves inconsistently across Node versions and
worker pooling, leaking the real homedir on CI (/home/runner) and
failing 6 tests. os.homedir() reads $HOME first on POSIX, so stub HOME
(and clear XDG_* vars) for deterministic, order-independent paths with
no builtin-module mock. Also make the device-auth body assertion parse
client_id rather than matching the exact string, since hostname is
appended best-effort and varies by machine.
* fix(cli): recover library ID mangled by Git Bash on Windows
Git Bash rewrites a leading-slash argument like /facebook/react into a
Windows path under the Git install dir (C:/Program Files/Git/facebook/react),
so "ctx7 docs" rejected it as an invalid library ID. This mainly affected
users running ctx7 through Claude Code.
Detect and undo the conversion before validation, and point users at the
//owner/repo escape for install layouts that aren't auto-detected.
* chore: add changeset
* fix(cli): use XDG dirs for context files
* fix(cli): harden XDG migration and cover previews dir
- Move `generate` previews to $XDG_CACHE_HOME/context7/previews (was the
last writer recreating ~/.context7)
- Make legacy->XDG migration best-effort and fall back to reading the
legacy file so loadTokens/readUpdateState never throw or silently log out
- Split update-check read (legacy fallback) from write (always XDG target)
- Ignore relative/empty XDG_* values per the spec
- Fix non-hermetic XDG_STATE_HOME test that moved the real ~/.context7
cli-state into a temp dir; add storage-paths tests and a migration-failure
fallback test
* chore: add changeset for XDG directories
* fix(cli): enforce 0o600 on credentials after migration
rename preserves the legacy file's mode, so a credentials file that was
group/world-readable in ~/.context7 stayed readable after migrating to the
XDG path. chmod the target to 0o600 on migrate, and re-assert it after every
write (writeFileSync's mode is ignored when the file already exists).
---------
Co-authored-by: Fahreddin Özcan <ozcanfahrettinn@gmail.com>
* fix(mcp): add no-op prompts/list and resources/list handlers
Some MCP clients (e.g. opencode) call prompts/list and resources/list
unconditionally and treat -32601 Method not found as fatal, rather than
honoring the negotiated capabilities. Advertise empty prompts/resources
capabilities and register no-op handlers so those clients can load the
server.
* remove explanatory comments
* fix(mcp): add no-op resources/templates/list handler
Advertising the resources capability invites clients to probe
resources/templates/list as well; without a handler that method
returned -32601, reintroducing the fatal-error behavior the
prompts/resources no-op handlers were added to avoid.
The localhost-callback path is gone. Every install — laptop, SSH,
Codespace, Docker, CI — goes through the same boxed prompt and
verification page. Three reasons to make this the default:
- The localhost flow was broken anywhere the browser couldn't reach
127.0.0.1:52417 (SSH, Docker, Codespaces). Auto-detection via
SSH_CONNECTION / $DISPLAY was a half-fix that depended on env
vars users don't always set.
- Device flow works everywhere, has no random port-binding behavior,
and still ends in the same long-lived ctx7sk- API key.
- One UX path is simpler to support than two.
Drops the --device flag (it was the opt-in for what's now the
default). Older CLI versions (<= 0.5.0) continue to work against the
unchanged auth endpoints, so pinned installs are unaffected.
The legacy localhost machinery in utils/auth.ts is left in place
for now — nothing imports it from commands/auth.ts anymore, and a
follow-up can delete it once we're confident no rollback is needed.
* feat(cli): OAuth 2.0 device authorization flow
Adds RFC 8628 device-code login for headless / remote hosts (SSH,
Codespaces, Docker, CI) where the existing localhost-callback flow
can't work — the browser opens on the user's laptop while the
callback listener runs on the remote host, so the redirect target
is unreachable.
Device flow prints a verification URL and short code, then polls the
new /api/oauth/device/token endpoint. The user visits the URL on any
device, signs in, and approves; the CLI receives the same ctx7sk- API
key it would have gotten from the legacy flow.
- shouldUseDeviceFlow() auto-detects via SSH_CONNECTION /
SSH_CLIENT / SSH_TTY and missing $DISPLAY on Linux.
- ctx7 login --device forces it. ctx7 setup picks it up
automatically when resolveCliAuth needs to authenticate.
- pollDeviceToken returns a "transient" status for network errors
and 5xx responses so a flaky backend or Upstash blip doesn't end
the session — keeps polling until the device_code TTL elapses.
* polish(cli): boxed device-code prompt, Press-Enter, whoami success line
Tightens up the device-flow UX so it matches the patterns from gh /
stripe / wrangler:
- Wrap the user_code + verification URL in a boxen rounded box with
a title, gray border, and the code as the visual headline (green
bold, indented on its own line).
- Add a "Press Enter to open the browser, or Ctrl-C to quit..."
confirmation step in TTY mode so the user can read the code before
the browser steals focus. Skipped under --no-browser or non-TTY.
- Replace the generic "Login successful!" line with
"Logged in as <email> (<team>)" by fetching /api/dashboard/whoami
with the freshly minted token. Falls back to the old text if the
call fails.
- Tighten the mockShouldUseDeviceFlow signature in the test mock so
the spread-into-mock pattern typechecks.
No behavior change to the localhost-callback flow.
* test(cli): cover shouldUseDeviceFlow + start/poll + performDeviceLogin
auth-utils: SSH/$DISPLAY heuristics for shouldUseDeviceFlow; the
form-encoded start-device-authorization request shape and error
propagation; pollDeviceToken status mapping for each RFC 8628 code,
5xx -> transient, network error -> transient, and unknown 4xx ->
throw.
auth-commands: performDeviceLogin happy path (approved -> saveTokens
called), denied/expired return null and don't save, transient errors
keep polling instead of bailing, slow_down bumps the interval
(verified with fake timers), startDeviceAuthorization throwing exits
without polling, browser-open behavior under openBrowser=true/false.
Also covers the performLogin selector: forceDevice=true and
shouldUseDeviceFlow=true both route through performDeviceLogin
without hitting the localhost callback path.
28 new tests; suite is 235/235.
* docs(cli): tighten device-flow comments
Drop restatement; keep only WHY-bearing notes.
* fix(cli): default poll interval to 5s per RFC 8628 §3.2
The CLI was treating `interval` as required and would NaN-crash if
a future server omitted it. Spec requires clients to default to 5
when absent.
DeviceAuthorizationResponse.interval is now optional, and
performDeviceLogin uses DEFAULT_DEVICE_POLL_INTERVAL_SECONDS (5) as
the fallback. Test covers the missing-interval path.
* fix(cli): rfc 8628 spec gaps — backoff, hostname, bare verification_uri
Three small spec-compliance fixes from the §3.5 / §3.3 / §5.4 audit:
- §3.5: poll loop now bumps intervalMs by 5s on `transient` results
(network errors and 5xx) — the RFC requires unilateral backoff on
connection timeouts, and mirroring the slow_down handler is the
simplest correct response.
- §3.3: the boxed prompt now prints the bare verification_uri
alongside verification_uri_complete so screen readers / paper /
another device can still type the short form.
- §5.4: startDeviceAuthorization sends `os.hostname()` so the server
can show it on the verification page; the user can confirm the
device they're authorizing matches the one running the CLI.
Transient-backoff test rewritten with fake timers (the new +5s wait
made the old real-timer assertion blow past the 5s default timeout).
resolveMode treated --api-key as a non-interactive marker and
short-circuited to MCP, but --api-key is equally valid for CLI +
Skills mode (which authenticates skill downloads). Users who
preferred CLI mode were silently locked into MCP unless they
also passed --cli.
Remove options.apiKey from the auto-MCP OR-chain. --mcp / --cli /
--stdio / --oauth / -y still skip the prompt; --api-key alone now
falls through to the interactive mode picker.
* fix(cli): wire --antigravity and remove broken --universal in setup
The setup command advertised --universal and --antigravity flags but
neither was wired through getSelectedAgents, so passing them silently
fell back to auto-detection and wrote to the wrong directory (see #2695).
Remove --universal from setup entirely, and add a full Antigravity
SetupAgent config: skills under .agent/skills, MCP config at
~/.gemini/antigravity/mcp_config.json with serverUrl for HTTP, and
detection of .agent or ~/.gemini/antigravity.
* fix(cli): align antigravity setup with official Google docs
After verifying against Google Codelabs / Google Cloud Community
docs, correct the Antigravity config:
- MCP global path: ~/.gemini/config/mcp_config.json (Antigravity 2.0
shared config, replacing the older ~/.gemini/antigravity/ path
which an outdated github/github-mcp-server install guide cited).
- HTTP key: httpUrl (Gemini convention; antigravity is Gemini-based).
The previous serverUrl was sourced from the same outdated guide.
- Rule: append to GEMINI.md / ~/.gemini/GEMINI.md (Antigravity reads
Gemini-family rules, not a vendor-specific file).
- Project MCP: none documented; projectPaths is empty and setupAgent
/ remove falls back to globalPaths so --project --mcp still writes
to the correct location.
Skills stay at .agent/skills to keep the in-repo IDE_PATHS convention
consistent across setup, skill, and generate commands.
* chore(cli): move Antigravity to 5th in agent selection list
Match the natural ordering users expect in the checkbox prompt:
Claude Code, Cursor, OpenCode, Codex, Antigravity, Gemini CLI.
* fix(cli): antigravity HTTP key is serverUrl, not httpUrl
Antigravity rejects the entry with "serverURL or command must be
specified" when given httpUrl. Switch the HTTP entry back to
serverUrl (the github-mcp-server install guide had this right even
though its file path was outdated).
Also tighten the empty-projectPaths fallback in remove.ts: it
incorrectly leaked global state into project-scope detection,
making `remove --project` report Antigravity whenever a global
~/.gemini/config/mcp_config.json existed. Project-scope detect/
remove now no-ops for agents with no project-level MCP, while
setup still falls back to the global path so --project --mcp
--antigravity writes to the file Antigravity actually reads.
* fix(cli): wire --antigravity into the remove command
Symmetric to the setup fix: --antigravity was missing from
UninstallOptions and getSelectedAgents, so users had no CLI path
to undo a `ctx7 setup --antigravity` install.