Skip to content

Latest commit

 

History

History
786 lines (635 loc) · 51 KB

File metadata and controls

786 lines (635 loc) · 51 KB

Onboarding a New AI Coding CLI onto the NeuroLink Proxy

Status: Understanding document — no code changes proposed yet

This document maps what it actually costs to add a fourth, fifth or sixth AI coding CLI to the NeuroLink proxy. It is the result of verifying an earlier audit (performed against v11.2.2, commit 0e935499) line by line against v11.2.3, commit 8728bbb3.

Everything below was re-read in this worktree. Where the earlier audit was wrong or imprecise, §2 says so plainly. Line numbers are from 8728bbb3 and were checked with grep -n / sed -n; they will drift.

Branch state, as of the audit (2026-08-21): feat/cli-support carried zero commits of its own — git log origin/release..HEAD was empty and origin/release was one commit ahead (67386e95, .releaserc.json only). Recorded as the starting point this audit worked from; it is a historical snapshot, not a claim about the branch today.


Contents

  1. The shape of the problem
  2. Corrections to the v11.2.2 audit
  3. Touch points: a config-writer-only CLI
  4. Touch points: a new-wire-format CLI
  5. What the existing machinery gives you — and where it stops
  6. Position: the account namespace is not a prerequisite
  7. The observability / routing split
  8. Defects
  9. Repo conventions this work must follow

1. The shape of the problem

The proxy is a Hono app bound to 127.0.0.1:55669 by default (proxy.ts:3864, :4532, :5590; port default 55669 at :4536). It terminates a CLI's own OAuth token, swaps in one from a pooled account, forwards upstream, and relays SSE back.

It exposes three wire surfaces:

Door Factory Upstream
POST /v1/messages createClaudeProxyRoutes (claudeProxyRoutes.ts:8316) api.anthropic.com
POST /v1/chat/completions createOpenAIProxyRoutes (openaiProxyRoutes.ts:351) translation engine / Anthropic loopback
POST /backend-api/codex/responses createCodexProxyRoutes (codexProxyRoutes.ts:568) chatgpt.com/backend-api

Dispatch is by URL path. A CLI is "onboarded" by writing that CLI's own config file so it points at the right door. Three such writers exist, hand-authored, sharing no abstraction:

CLI Writer Restore Target
Claude Code setClaudeProxySettings proxy.ts:625-652 clearClaudeProxySettings :654 ~/.claude/settings.json (:248)
OpenCode setOpenCodeProxySettings proxy.ts:734-784 clearOpenCodeProxySettings :786-843 opencode.json (:722)
Codex setCodexProxySettings proxy.ts:922-994 clearCodexProxySettings :996 ~/.codex/config.toml (:857)

/v1/chat/completions is the important door and nothing is pointed at it. /v1/chat/completions speaks plain OpenAI Chat Completions and requires no inbound authentication at all — grep for authorization in openaiProxyRoutes.ts returns nothing. The OpenCode writer supplies a placeholder apiKey: proxyKey || "neurolink-proxy" (proxy.ts:778) purely because the AI SDK demands a non-empty string. Any CLI that can be told a base URL and an arbitrary API key lands here with zero protocol work.

Coverage, verified on this machine

CLI Installed Verdict Mechanism
Claude Code yes live ANTHROPIC_BASE_URL
Codex yes live config.toml base_url + wire_api="responses"
OpenCode 1.3.13 live provider.neurolink in opencode.json (fixed: #1366/#1367)
Qwen Code @qwen-code/qwen-code@0.17.0 live security.auth.baseUrl → /v1/chat/completions
Copilot CLI @github/copilot@1.0.56 live COPILOT_PROVIDER_* via a sourceable env script
Hermes Agent no easy (unverified on disk) ANTHROPIC_BASE_URL / OPENAI_BASE_URL
Gemini CLI @google/gemini-cli@0.53.0 moderate GOOGLE_GEMINI_BASE_URL, API-key mode only
Amp 0.0.1780291930-gf6d536 hard AMP_URL honoured, but fronts a proprietary backend — see §7c
Cursor cursor-agent 2026.05.28 refuted env vars are dead code — proven by live test
Antigravity 1.107.0 hard proprietary Cascade protobuf
Grok CLI no unconfirmed not installed
Kiro CLI no hard fixed AWS hosts, OAuth device flow

Near-term: 2 live → 6 — five of those are now live (Claude Code, Codex, OpenCode, Qwen Code, Copilot CLI), with config-writer work only and zero new route modules. The remaining one is Hermes Agent, which the table above marks easy but unverified on disk.

The eleven-CLI roster above is PokeTokenBar's list plus Qwen Code, which this audit added after verifying it directly. It is still not the whole field: SARA tracks Amp, which neither of the others does, while PokeTokenBar tracks Hermes, Kiro, Antigravity and Grok, which SARA does not. The union is 12. Scope coverage against the union, not any single list. Amp is now exhaustively assessed — see §7c — and is not onboardable as a config-writer client.


2. Corrections to the v11.2.2 audit

The earlier audit's structural claims hold. Five of its specific claims do not.

2.1 The cost defect is attributed to the wrong route — and the failure mode is different

Claimed: proxyTracer.ts:497 and :826 hard-code calculateCost("anthropic", …), so Codex traffic to chatgpt.com is costed against Anthropic's price table.

Actually:

  • There are four hard-coded "anthropic" sites, not two: proxyTracer.ts:497, :826, :874 (all calculateCost) and :883 (TelemetryService.recordAIRequest("anthropic", …)).
  • codexProxyRoutes.ts never imports ProxyTracer and never calls calculateCost. Its logRequest() object literal (:322-339) has no token keys at all, and it never parses usage out of the SSE stream. Codex traffic is not mis-priced — it is entirely unaccounted. Fixing it starts with parsing usage, not with the pricing call.
  • The hard-coded provider actually mis-prices /v1/chat/completions, which routes through ModelRouter to any provider.
  • The failure mode is usually $0, not a wrong number. findRates returns undefined when nothing matches, and calculateCost then returns 0 (pricing.ts:755-758). The anthropic table (pricing.ts:26-161) has no _default sentinel — the first one is at :406. So findRates("anthropic", "gpt-4o") → undefined → $0. Real mis-pricing only happens when a Claude-named model is routed elsewhere (e.g. a claude-* alias mapped to Vertex Gemini), which prices Gemini traffic at Sonnet rates.
  • Compounding it: ProxyTracer.model is private readonly (proxyTracer.ts:248), fixed at construction to the model the client asked for. setModelSubstitution() only writes span attributes. So even with a correct provider string, cost is computed against the requested model, not the served one.

Good news: pricing.ts already ships 18 provider tables including openai with a "gpt-5.1-codex" entry (:162+). For the /v1/chat/completions path this is a parameter change, not new pricing data.

2.2 "Nothing identifies the calling CLI at request time" is false — and this is an onboarding hazard

Claimed: detectClientApp() (proxyTracer.ts:226-235) is the only User-Agent sniff and it only labels trace spans.

Actually: there is a second sniff that drives real request behaviour. isLikelyClaudeClient() (claudeProxyRoutes.ts:2341-2350) tests headers["user-agent"]?.startsWith("claude-cli/") (among other signals), and its result gates:

  1. which OAuth beta header set is sent — CLAUDE_CODE_OAUTH_BETAS vs NON_CLAUDE_OAUTH_BETAS (:7221-7236);
  2. whether polyfillOAuthBody() preserves the client's own system-prompt / agent identity blocks verbatim or strips and relocates them;
  3. maybeRefreshClaudeSnapshot() (:2398).

This matters directly for CLI #4 and #5. Any non-Claude CLI pointed at /v1/messages via ANTHROPIC_BASE_URL — Hermes is exactly this case — takes the NON_CLAUDE_OAUTH_BETAS branch and a different system-prompt path. That is probably correct behaviour, but it is behaviour, and it must be tested rather than assumed.

2.3 "314,116 requests and no ledger" is overstated

usageStats.ts (1,478 lines, zero occurrences of inputTokens / outputTokens / totalTokens / cost) is confirmed. But neurolink proxy analyze is a real, wired command (src/cli/parser.ts:260) and proxyAnalysis.ts:437-527 reads the same proxy-YYYY-MM-DD.jsonl files requestLogger.ts writes and sums inputTokens, cacheReadTokens and cacheCreationTokens per window.

The gap is narrower and more specific than "no ledger": outputTokens and cost are absent from proxyAnalysis.ts entirely, and nothing aggregates the Codex engine at all (§2.1).

2.4 OpenCode is two bugs, not a one-line fix

The path bug is confirmed and is genuinely one line — but a path-only fix leaves a second, independent bug in place.

  • getOpenCodeConfigDir() (proxy.ts:711-720) returns ~/Library/Application Support/opencode on darwin. The installed OpenCode 1.3.13 binary embeds the unmodified xdg-basedir npm package (XDG_CONFIG_HOME || join(homedir, ".config")) with no platform branch at all. Empirically: ~/.config/opencode/opencode.json exists (2,640 bytes, holds a working custom provider that opencode models confirms is loaded); ~/Library/Application Support/opencode does not exist. Deleting the darwin branch is the whole fix.
  • The second bug survives that. setOpenCodeProxySettings returns Promise<void> (:734) and the call sites print ✓ Auto-configured OpenCode settings unconditionally (:3491-3493, :5403-5405). setCodexProxySettings returns Promise<boolean> and its ✓ is gated on it (:3502, :5413). So even after the path fix, a user without OpenCode installed still gets a success message for work that did not happen.

Origin of the mistake: the OpenCode binary does contain the literal /Library/Application Support/opencode — as systemManagedConfigDir(), an MDM / enterprise policy tier at the filesystem root (no $HOME), paired with /etc/opencode and %ProgramData%\opencode. Someone found that string and read it as the per-user path.

It is also baked into our own docs. docs/features/opencode-proxy-support.md:67 asserts the macOS path is ~/Library/Application Support/opencode/opencode.json, while :313-323 of the same file shows resolution code with no darwin branch that would compute ~/.config/opencode. The doc contradicts itself, and it is titled "Implemented & Verified".

How it escaped verification: §11's E2E playbook runs the dev proxy with --dev, which by its own option description performs "no client auto-configuration" (proxy.ts:3910-3917), and hand-copies a fixture to /tmp/opencode-proxy-test/opencode.json. The writer is never executed. There is also zero automated coverage — grep for setOpenCodeProxySettings across test/ returns nothing; only two unused fixture JSON files mention OpenCode.

2.5 Smaller drifts

Claim Correction
RouteGroup at types/server.ts:395-448 RouteGroup is :443-458. :395-431 is RouteDefinition.
Copilot works via OPENAI_API_KEY+OPENAI_BASE_URL True only with COPILOT_ENABLE_ALT_PROVIDERS=true (app.js:6119). The COPILOT_PROVIDER_* path has no such gate.
Gemini OAuth is pinned to cloudcode-pa.googleapis.com Pinned by default, but CODE_ASSIST_ENDPOINT overrides it unconditionally (getBaseUrl(), chunk :309590-309592), documented by Google as dev/test-only.
Claude writer range 622-651 Function is 625-652; :623 is PROXY_MANAGED_KEYS, correct.
Cursor refutation Upheld, and strengthened. A live run with CURSOR_LOCAL_AGENT_BASE_URL set returned Authentication required — the env vars had zero effect. Do not build for it.

3. Touch points: a config-writer-only CLI

This section described eleven edits. It is now two.

The writers moved behind a CliProxyClientConfigurator contract (src/lib/types/proxyClient.ts), one module per client under src/cli/proxy-clients/, assembled by registry.ts. The four duplicated call-site blocks in proxy.ts collapsed into applyAllClients() and restoreAllClients().

To add a CLI that only needs to be told a base URL:

# File What to do
1 src/cli/proxy-clients/<cli>.ts Implement CliProxyClientConfigurator: id, displayName, detect(), apply(proxyBaseUrl), restore(proxyBaseUrl). Snapshot the user's prior config before overwriting, and return false from apply when nothing was written.
2 src/cli/proxy-clients/registry.ts Add it to PROXY_CLIENT_CONFIGURATORS.

Plus a test and a doc entry, as for any change. Nothing in proxy.ts is touched at all.

The one client that needs a shell, not a file

Copilot CLI reads its provider settings from process.env only — app.js resolves COPILOT_PROVIDER_BASE_URL and siblings directly, and ~/.copilot/config.json (which announces itself as "managed automatically") carries no provider block. There is no file the proxy can write that Copilot will read.

Rather than edit a shell profile — which lives outside the proxy's blast radius and runs on every shell — the configurator writes ~/.neurolink/copilot-env.sh and expects one line in your profile:

[ -f ~/.neurolink/copilot-env.sh ] && . ~/.neurolink/copilot-env.sh

The proxy deletes the script on stop, and the -f guard makes a missing file a no-op, so no NEW shell picks up a stale export. A shell that already sourced it keeps the variables for its own lifetime — deleting a file cannot unset variables in a running process. Run unset COPILOT_PROVIDER_URL COPILOT_PROVIDER_API_KEY (or start a new shell) if you stopped the proxy in a session that had it loaded.

Note also that Copilot's OPENAI_API_KEY + OPENAI_BASE_URL path works only with COPILOT_ENABLE_ALT_PROVIDERS=true; the COPILOT_PROVIDER_* path has no such gate, which is why it is the one used.

What the contract enforces

Three defects came from the writers disagreeing with each other. The contract makes each one unrepresentable:

  • detect() is required, so a writer cannot create config for a CLI that was never installed — the bug Claude Code shipped with.
  • apply() returns boolean, so a caller cannot print ✓ for work that did not happen — the bug OpenCode shipped with.
  • The base-URL suffix belongs to the client (/v1 for OpenAI-compatible clients, bare origin for Codex), so no call site has to remember it.

Behaviour the loops preserve

applyAllClients wraps each client independently: one failing can neither stop the others nor abort shutdown. The daemon-start path reports failures at debug level and the setup wizard prints a visible warning — deliberately different, and both preserved. The proxy guard path still keys its cleared flag off Claude Code specifically.

4. Touch points: a new-wire-format CLI

For Gemini CLI, which needs Google's generateContent shape and usageMetadata translation. Everything in §3 plus:

# File Lines What to do
A src/lib/server/routes/<cli>ProxyRoutes.ts new create<Cli>ProxyRoutes(basePath = ""): RouteGroup. Model it on codexProxyRoutes.ts (self-contained) rather than claudeProxyRoutes.ts (8,300+ lines).
B src/cli/commands/proxy.ts :1978-1983 Add the dynamic import alongside the other three.
C src/cli/commands/proxy.ts :2121-2145 Add to the hand-assembled allProxyRoutes array. Mounting at :2148-2150 is generic and needs no change.
D src/lib/server/routes/index.ts :11-29, :36-66 The second seam. Import, re-export, and add to createAllRoutes — otherwise the door is CLI-only, as Codex is today.
E src/lib/types/server.ts :1401-1409 Add a CreateRoutesOptions flag if the door should be independently toggleable.
F docs/guides/server-adapters/api-reference.md :657-660, :680-686 Now documented — the proxy/claudeProxy/openaiProxy flags and all three proxy factories are listed. Keep it current when a door is added, or the next provider repeats the drift that made this row necessary.
G src/lib/proxy/modelRouter.ts resolve(), :50-65 Only if model-name prefixes need extending (gemini- → vertex, claude- → anthropic). Codex bypasses ModelRouter entirely.
H <cli>OAuth.ts + <cli>AccountUsage.ts new Only if the CLI brings its own subscription pool. See §6.

Two things you do not need to touch, contrary to reasonable expectation:

  • src/lib/proxy/proxyDispatcher.ts — installs one global undici keep-alive Agent process-wide. No provider or path names appear in the file.
  • ProviderTransportCoordinator — provider-agnostic, but instantiated only inside claudeProxyRoutes.ts:249. There is no global instance to register into; a new door may optionally construct its own.

A naming trap: there are two unrelated ModelRouter classes. src/lib/proxy/modelRouter.ts is the HTTP one; src/lib/utils/modelRouter.ts is an unrelated task-classification router. Grepping without checking the import path finds the wrong one.


5. What the existing machinery gives you — and where it stops

What RouteGroup genuinely provides

RouteGroup (types/server.ts:443-458) is { prefix, routes: RouteDefinition[], middleware?, auth?, roles? }, and RouteDefinition (:395-431) carries method, path, handler, plus optional schemas, auth, rate limits and streaming config.

The real payoff is at the mount: proxy.ts:2148-2150 iterates allProxyRoutes and calls app[method](route.path, …) generically, wrapping every route in the same draining check, request-metadata tracking and error envelope. A new door inherits all of that for free simply by being in the array. That is a genuine, load-bearing abstraction.

Where it stops — "registry" overstates it

  • There is no plugin loader, manifest, or dynamic registry for route groups. The array at proxy.ts:2121-2145 is hand-edited, and createAllRoutes in routes/index.ts is a second hand-edited list. The two are not derived from each other, which is exactly why Codex exists in one and not the other.
  • The config writers had no abstraction — since fixed; kept here as the finding that motivated the fix. They now sit behind a CliProxyClientConfigurator contract with one module per client under src/cli/proxy-clients/, assembled by registry.ts (see the section above). What follows is what the audit found at the time, which is why the strategies still differ per client: Claude and OpenCode do JSON round-trips with an inline snapshot key (__proxy_original_env, __proxy_original_neurolink); Codex does regex-driven TOML text manipulation with a marker-delimited block and a separate sidecar snapshot at ~/.neurolink/codex-proxy-snapshot.json. At the time this was a notable departure from the codebase's own stated Factory+Registry convention for providers, processors, chunkers and rerankers.
  • The SDK seam is untested by this repo's own CLI. neurolink proxy never calls createAllRoutes; neurolink server calls it but never passes any proxy flag (grep -i proxy src/cli/commands/server.ts → nothing). The proxy / claudeProxy / openaiProxy options exist solely for external SDK consumers, and no code in this repo exercises them.

The single highest-leverage refactor was extracting a ClientConfigurator registry — { id, detect(), apply(baseUrl), restore(baseUrl) } — so the four duplicated call-site blocks collapse into one loop. This has since been done: the writers live under src/cli/proxy-clients/ behind CliProxyClientConfigurator, and the call sites are applyAllClients() / restoreAllClients(). It turned "eleven edits across one huge file" into "one new file plus one registry line," and it is what would have prevented both halves of the OpenCode bug. Do not re-extract it; onboarding a new CLI now means adding a module and a registry line.


6. Position: the account namespace is not a prerequisite

The earlier audit's headline recommendation was to generalise the account-key namespace before CLI #5 and #6. I disagree, and the code says so.

The facts are right

accountSelection.ts is 52 lines and entirely Anthropic-shaped — LEGACY_ANTHROPIC_ACCOUNT_KEY, ENV_ANTHROPIC_ACCOUNT_KEY, normalizeAnthropicAccountKey(), anthropicAccountKeysEqual(), all normalising to an anthropic: prefix. Codex runs a parallel namespace via CODEX_ACCOUNT_PREFIX = "codex:" (codexAccountUsage.ts:29). codexProxyRoutes.ts imports nothing from accountSelection.ts — verified, zero hits. Pooling really is written twice.

But it does not block a config-writer CLI — at all

Trace an inbound /v1/chat/completions request:

  • If ModelRouter resolves the model to anthropic, the handler forwards it by loopback to the proxy's own /v1/messages (openaiProxyRoutes.ts:396, bridge at :141-148). Its comment is explicit: this "reuses the full Claude passthrough path (OAuth account rotation, retry, SSE interception, etc.)". The request rides the existing Anthropic pool, unchanged.
  • Otherwise it falls through to the translation engine and ctx.neurolink.stream() — normal SDK credential resolution, no account pool involved at all.

Either way, accountSelection.ts, accountCooldown.ts, accountQuota.ts and the token store are untouched. Reinforcing this: account selection has no concept of caller identity. clientApp exists but is telemetry-only. Pooling is scoped by provider key prefix, never by which CLI called. A new caller is invisible to that subsystem by construction, not by luck.

So: Copilot CLI, Hermes and a fixed OpenCode need zero namespace work. Sequencing a refactor ahead of them would be pure delay.

And for a CLI that does bring its own pool

Copy the Codex pattern. The repo's own CLAUDE.md already prescribes it — "a new <provider>OAuth.ts, a <provider>AccountUsage.ts quota parser, and a <provider>ProxyRoutes.ts engine — do not modify the Anthropic hot path" — and the code supports it cheaply: accountCooldown.ts (162 lines) and the storage half of accountQuota.ts (:500-541) are already provider-agnostic Record<string, T> keyed by an opaque accountKey, with no prefix branching. tokenStore.listByPrefix() is generic. Cost: roughly three new files, ~800–1,000 lines, zero risk to the Anthropic hot path.

Generalising the namespace first would also mean confronting the migration CLAUDE.md explicitly defers: Anthropic quota is keyed by bare label and persisted that way in every user's ~/.neurolink/account-quotas.json, so re-keying means either discarding every stored snapshot or shipping a tolerate-both migration for a release.

Recommended sequence:

  1. Fix OpenCode (path + boolean return + the doc). Smallest possible change, restores a feature users already believe they have.
  2. Add Copilot CLI. Env-var only, existing door, no new route module.
  3. Add Qwen Code and Hermes. Qwen is the same shape as Copilot (OPENAI_BASE_URL, existing door). Hermes needs the isLikelyClaudeClient branch (§2.2) tested deliberately, since it lands on /v1/messages without a claude-cli/ User-Agent.
  4. Then extract the ClientConfigurator registry, with four real implementations to generalise from rather than three.
  5. Only when a CLI with its own subscription pool arrives, copy the Codex pattern — and revisit the namespace only if a fourth pool appears after that.

7. The observability / routing split

These are independent capabilities with different ceilings, and treating them as one thing has hidden how cheap the second is.

Routing tops out at 5–6 of 10. It depends on vendors shipping base-URL overrides nobody here controls. Kiro and Antigravity are structurally closed. Cursor looked like the cleanest win in the matrix and turned out to be dead code.

Reading each CLI's own local logs reaches 10 of 10. No auth, no vendor cooperation, no proxy in the request path. It works for CLIs that can never be routed, and it recovers months of history the proxy can never see. It is also an independent source of truth — it would have caught §2.1 immediately.

Prior art — start with SARA, not PokeTokenBar

The file-walking layer already exists in TypeScript, one repo over. SARA (the sara project, a sibling checkout) ships an eight-CLI session reader registry at packages/agents/src/sessions/:

  • registry.ts:171-213 — dedicated lazy-factory readers for Claude, Codex, OpenCode and Gemini, registered via registerReader(id, async () => …) with dynamic imports — the same shape as providerRegistry.ts.
  • registry.ts:228 — a generic ProviderSpec loop covering Cursor, Amp, Qwen and Copilot, with the specs in jsonlReaders.ts:94-200.
  • Per-CLI on-disk paths already resolved: ~/.cursor/projects/…, ~/.local/share/amp/threads, ~/.qwen/projects/…, ~/.copilot/session-state.
  • registry.ts:252 keeps const VERIFIED = ["claude", "codex"] and exposes it as verified on each descriptor (:272) — an honesty marker separating readers confirmed against real data from ones written to spec. Worth copying that idea regardless of what else we take.

What SARA does not do is extract usage. Only claudeReader.ts touches tokens at all (18 hits for token/usage/cost; codexReader.ts, geminiReader.ts, opencodeReader.ts and jsonlReaders.ts each have zero) — and it does so only to compute current context-window occupancy, deliberately non-cumulative so it self-corrects after compaction (claudeReader.ts:52-69). The other readers parse transcript parts and stop.

So the split is: take file-walking, path resolution, provider detection and the registry shape from SARA — same language, already written. Take the token extraction and aggregation semantics from PokeTokenBar, which is the part SARA lacks and the part that is actually hard.

The poketokenbar project is ~3,600 lines of Swift covering these ten formats: LocalUsageReader.swift (1,264), LocalUsageCache.swift (373), LocalAdditionalUsageProvider.swift (1,077), LocalAntigravityUsageReader.swift (542), LocalUsageProvider.swift (171).

A TypeScript port is smaller than a transliteration, because Node needs neither Swift's actor/NSLock concurrency scaffolding nor BinaryLocator.swift (230 lines that exist only so a Finder-launched .app can see shell exports).

Per-format estimates: Claude ~120 lines, Gemini ~80, Hermes ~70, OpenCode ~150, Grok ~180, Cursor ~180 + ~150 shared incremental-SQLite scaffolding. Risk concentrates in two: Codex (~600 lines — a session-DAG/prefix-match reconciliation algorithm, not a file parse) and Antigravity (a mini protobuf codec with no schema). Budget and review those separately from the other eight.

Three things a port must decide up front:

  1. A tri-state cost field. Claude/Gemini/Grok/OpenCode/Hermes report real or well-modelled cost; Cursor/Copilot/Antigravity are flat-rate and report none; Kiro's numbers are a byte heuristic and must never be shown with the same confidence. This maps onto the existing jsonRepaired / jsonTruncated "exact or salvaged" convention in CLAUDE.md rule 3 — reuse it rather than invent one.
  2. Dedup is per-format, not generic. Claude/Grok/OpenCode use content-hash keep-max; Gemini needs last-write-wins because message_update records intentionally supersede; Cursor/Copilot need SQLite rowid high-water marks plus a didReset full-rescan fallback for post-VACUUM. Codex needs the DAG resolution.
  3. SQLite is a real dependency decision. Six of ten formats need it. package.json lists sqlite3 in onlyBuiltDependencies but the lockfile shows it is not actually installed — the only SQLite reference is better-sqlite3 as an unmet optional peer of @juspay/hippocampus. Given this repo's recent CI commits specifically about avoiding unnecessary native binaries (6f839235, a3490501), node:sqlite (Node 22.5+) deserves serious consideration over a native module.

Only 4 of 10 formats are testable against real data on this machine today: Claude (16,830 .jsonl files), Codex (122 rollouts), Gemini (9 files), OpenCode (a 305 MB opencode.db). PokeTokenBar's own per-format test fixtures are worth reusing for the other six. Note also that ~/.copilot/session-store.db on this machine has no assistant_usage_events table — PokeTokenBar's Copilot reader targets a schema that has since changed, so "the file exists" is not evidence a format is portable.

Where it belongs

Not on this branch. feat/cli-support is about routing — config writers and route modules. Local-log reading shares no code with any of it: it never touches proxy.ts, routes/, or the account pools, and it is explicitly not a modification of accountUsage.ts (which polls providers' remote usage APIs for accounts in NeuroLink's own pool, covering only Anthropic and Codex).

It should be its own branch and its own subsystem — a new src/lib/localUsage/ with one reader per CLI behind a lazy dynamic-import registry mirroring providerRegistry.ts, and types in src/lib/types/localUsage.ts per rule 2. One wrinkle worth designing for early: PokeTokenBar is a long-running menu-bar app, so it keeps Kiro's and Codex's cross-scan merge state in memory. A CLI invocation has no equivalent, so that state must be persisted to disk.


7b. Feasibility, verified live (2026-08-22)

Four rows the audit left unresolved were tested against a real proxy on this machine rather than reasoned about. The method is the one that refuted Cursor: start neurolink proxy start --port 9911, point the CLI's documented override at it, run one trivial command, and see whether anything arrives.

CLI Override Result
Gemini CLI 0.53.0 GOOGLE_GEMINI_BASE_URL Honoured — traffic arrives. Verdict upgraded from assumed to verified.
Amp 0.0.1780291930 AMP_URL Honoured — but fronts a proprietary backend. Not onboardable; see §7c.
Hermes Agent — Cannot be verified: not installed, no binary and no config dir on this machine.
Grok CLI — Cannot be verified: not installed; two rival npm packages claim the name.

Gemini CLI — the override is real; the door is what is missing

With GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:9911 the CLI reached the proxy and failed with ModelNotFoundError: 404 Not Found from classifyGoogleError. That is the correct answer from a proxy with no generateContent route: the redirect worked, and there was nothing to answer it.

So the remaining work is exactly §4's new-wire-format list and nothing more — no vendor cooperation is needed, and the override does not have to be discovered or negotiated. Two operational notes for whoever builds it: the CLI refuses to run outside a trusted directory (--skip-trust or GEMINI_CLI_TRUST_WORKSPACE=true for headless testing), and it issues a generateJson call during startup, so the door has to answer more than just the user's turn.

Amp — override honoured, but it brings its own front door

AMP_URL is genuinely live, unlike Cursor's inert variables: pointed at the proxy, Amp built its login URL against it — http://127.0.0.1:9911/auth/cli-login?authToken=… — and waited for a code.

That is also the finding. Amp does not authenticate with a bearer token the way the five live CLIs do; it expects an OAuth-style CLI login flow at its own endpoint before any API traffic. Onboarding it therefore means implementing Amp's auth surface, not writing a config file, which puts it in the new-wire-format class with Gemini rather than the config-writer class. Its bundle vendors Google's GenAI SDK, so generateContent strings inside it describe a dependency and not Amp's own wire — worth knowing before someone greps for them and concludes otherwise.

This was the assessment with no AMP_API_KEY set, so the login prompt was as far as the CLI got. §7c re-runs this with a key supplied, past the login prompt, and reaches a materially worse conclusion: Amp is not "new-wire-format like Gemini," it is a proprietary control-plane CLI, and pointing it at this proxy makes it fail every invocation rather than merely fail to find a route.

Hermes and Grok — unverifiable here, and that is the finding

Neither is installed: no binary on PATH, no ~/.hermes or ~/.config/hermes, nothing under any package root. The audit's "easy (unverified on disk)" verdict for Hermes remains exactly that — it was never validated, and the ANTHROPIC_BASE_URL claim comes from documentation rather than from a bundle.

Recording this rather than leaving the rows ambiguous: the blocker is availability, not difficulty, and the first step for either is installing it — not writing a configurator against a guessed config surface.


7c. Amp CLI, exhaustively assessed (2026-08-29)

§7b's Amp finding stopped at the login prompt because no AMP_API_KEY was set. Supplying a placeholder key (AMP_API_KEY=test-capture-key) bypasses the OAuth flow and lets the CLI proceed — which is what actually settles whether Amp is onboardable as a CliProxyClientConfigurator, not merely whether AMP_URL is honoured.

Config surface, confirmed from the installed binary (0.0.1780291930-gf6d536, @sourcegraph/amp wrapping @ampcode/cli) and matching a live amp --help:

  • AMP_URL (env, highest precedence; default https://ampcode.com/) — the same variable §7b tested.
  • A persisted "url" key in the global settings file, default ~/.config/amp/settings.json (resolved via XDG_CONFIG_HOME || ~/.config — no macOS-specific Application Support branch; confirmed both from the bundle's path-resolution constants and from amp --help reporting exactly that path on this machine).
  • AMP_SETTINGS_FILE — overrides the settings-file path outright; used throughout this investigation to keep every probe out of the user's real ~/.config/amp/settings.json.
  • AMP_HOME, AMP_LOG_LEVEL, AMP_LOG_FILE — logging/home overrides, not routing-relevant.
  • AMP_API_KEY (env) or a settings-file apiKey — bearer credential.

None of this contradicts §7b. What's new is what happens once the CLI is actually let past login.

Two probes against a local capture server (not the live proxy), both with AMP_URL and AMP_SETTINGS_FILE redirected into a scratch directory and AMP_API_KEY set to a placeholder:

  1. A naive stub returning 200 {"error":"capture-server-stub"} for every request. Amp's client reads $.error.code, gets undefined back from a string error, and fails with a garbled getUserInfo error: undefined — an artifact of the stub's shape, not a finding on its own, but it already shows the first call Amp makes once past login is POST /api/internal?getUserInfo.
  2. A properly-shaped server returning {"ok":true,"result":{...}} envelopes for getUserInfo and loadPlugins. With those two calls satisfied, Amp proceeded to POST /api/thread-actors with body {"executorType":"local-client","agentMode":"smart"} — a third, distinct proprietary RPC that provisions the agent's execution thread. No model call was ever attempted; the run was stopped once this third call landed, since answering it too would mean building out Amp's orchestration layer, not a completions endpoint.

So the actual call sequence, before Amp ever needs a model response, is: /api/internal?getUserInfo → /api/internal?loadPlugins → /api/thread-actors. All three are bespoke JSON-RPC-shaped endpoints private to Amp's backend, and none of them map onto any of the proxy's four wire doors (/v1/messages, /v1/chat/completions, /backend-api/codex/responses, /v1beta/models/{model}:generateContent).

The bundle also contains /api/provider/{anthropic,openai/v1,google} sub-paths. These are not something the CLI calls directly once AMP_URL is repointed — they are the internal sub-paths Amp's own backend (or a self-hosted Amp Enterprise gateway) uses to reach upstream model providers. The CLI cannot reach them without first clearing the /api/internal and /api/thread-actors calls above, so implementing just the provider sub-paths would not unblock anything. (This also corrects §7b's aside about vendored Google GenAI SDK strings: that observation stands on its own, but it is a separate red herring from these internal provider sub-paths, not the same one.)

Confirmation against the real live proxy (neurolink proxy start, http://127.0.0.1:55669, the same instance used elsewhere in this document): pointing an installed Amp at it with AMP_API_KEY set reproduces the same /api/internal?getUserInfo call, which the proxy answers with its default 404 (no such route exists on any of its four doors), and Amp exits immediately:

Error: API request for getUserInfo failed: 404

No model call is attempted — the CLI fails structurally before reaching that stage, so this is not a case of "wrong door, add a route" the way Gemini's ModelNotFoundError: 404 was in §7b. A 404 on /api/internal cannot possibly reach the proxy's upstream-attempt accounting, so the routing proof here is the 404 response and Amp's fatal exit themselves, not a stats.totalAttempts counter delta — that counter is only a clean signal for a positive routing proof, and this is a negative one by construction.

Verdict: Amp is not onboardable as a CliProxyClientConfigurator, and no amp.ts was written. This is a different failure mode from Copilot's sourceable-script problem (§3's "one client that needs a shell, not a file"): an un-sourced Copilot script is inert — Copilot keeps working exactly as it did before the proxy touched it. Pointing Amp's AMP_URL at this proxy is actively harmful — every invocation hard-fails with a fatal error until an operator reverts the setting, because the proxy does not implement (and cannot cheaply implement) Amp's proprietary auth/user-info RPC, its thread orchestration RPC, or the OAuth login flow found in §7b. Shipping a configurator that "successfully" applies this URL would leave users with a broken amp CLI and a proxy that reports success — worse than not onboarding it at all, and precisely the outcome this document's §3 and the Copilot precedent warn against reproducing.

registry.ts's six configurators (Claude Code, OpenCode, Codex, Qwen Code, Copilot, Gemini CLI) are unchanged; no config file outside this worktree's scratch directory was ever written, and no real ~/.config/amp or ~/.amp was touched. Amp's coverage-table verdict (§1) is revised from an unassessed slot in the twelve-CLI union to hard — alongside Antigravity and Kiro, not alongside Gemini's config-writer-adjacent "moderate."

Worth flagging for whoever revisits this against a newer Amp release: the /api/internal query-string RPC style (?getUserInfo, ?loadPlugins) reads as intentionally proprietary rather than an accidental subset of some open protocol — there is no partial-compatibility shortcut here to chase.


8. Defects

Eleven filed on juspay/neurolink. Eight are fixed and released (v11.13.0 and v11.14.0, via #1399-#1402); three remain open.

# Defect Location Issue Status
1a OpenCode writer targets a path OpenCode never reads on macOS proxy.ts:711-720 #1366 fixed
1b ✓ Auto-configured OpenCode prints unconditionally — survives 1a's fix proxy.ts:734, :3491, :5403 #1367 fixed
1c The same wrong path is asserted in our own docs, which contradict themselves opencode-proxy-support.md:67 vs :313-323 #1366 fixed
1d Zero test coverage for the writers; §11's playbook uses --dev, which skips them test/ #1368 fixed — all three writers covered
2a Codex engine has no token/cost accounting at all — never parses usage codexProxyRoutes.ts:322-339 #1369 fixed
2b Provider hard-coded "anthropic" at four sites proxyTracer.ts:497, :826, :874, :883 #1370 fixed
2c Cost computed against the requested model, not the served one proxyTracer.ts:248 #1370 fixed
3 proxyAnalysis.ts aggregates no outputTokens and no cost proxyAnalysis.ts:437-527 #1371 fixed
4 SDK server-adapter docs omit all three proxy factories and the proxy flags api-reference.md:657-686 #1372 docs written, issue still open
5 Codex CLI model discovery 404s — proxy has /responses but not /backend-api/codex/models codexProxyRoutes.ts:615 #1383 open
6 Client config writers: no locking or atomic writes between apply and restore src/cli/proxy-clients/ #1384 open

Security note, not a defect: the proxy takes no inbound authentication on any door. That is defensible at the 127.0.0.1 default, but --host accepts 0.0.0.0, which would expose pooled OAuth subscription tokens to the network. Any new writer must not assume an auth layer exists.


9. Repo conventions this work must follow

  • CLAUDE.md rules 2, 7–15 are ESLint-enforced. Most relevant here: types go in src/lib/types/ only (rule 2); type, never interface (rule 7); barrel-only internal type imports (rule 13); no double assertions (rule 14).
  • Rule 15 — tests are end-to-end only. Import from dist/index.js, or drive node dist/cli/index.js. Never mix dist and src in one suite. Both test/continuous-test-suite-proxy.ts and -codex.ts are on the e2e-tests-only allow list in eslint.config.js:262-291 with written justifications, and the codex suite deliberately keeps its last two cases driving the built CLI. A new proxy suite should follow that shape, and adding to the allow list is a review decision.
  • Keep payloads out of assertion messages. defineSuite downgrades a failure to SKIP when the message matches isExpectedProviderError(), so quoting a payload containing 502 or ECONNREFUSED turns a real failure green. Sanity-check any new suite by breaking one assertion on purpose.
  • CI has six jobs, not two. CLAUDE.md's note is incomplete: alongside test and provider-safety-net there are build-check, proxy-performance ("Proxy Performance Gates", runs pnpm run proxy:performance against proxyLifecycle.ts / proxyActivity.ts), quality-gate (validate, commit-message validation, tsc --noEmit --strict) and semantic-release-validation. provider-safety-net also runs several suites the pre-push hook does not — test:providers-mocked, test:error-classifier-contract and the bedrock / sagemaker / anthropic / aistudio characterization suites. A new proxy engine can trip proxy-performance.
  • Docs PRs are gated. docs-pr-validation.yml triggers on docs/** and runs a Docusaurus typecheck and build without continue-on-error. Frontmatter and link checks are soft.
  • Decide the orphaning question deliberately. codex-proxy-support.md and opencode-proxy-support.md have no frontmatter, are absent from docs-site/scripts/sync-docs.ts, and are unlinked from docs/features/index.md. Following that precedent exactly means a new doc is not published and not discoverable. This document follows the precedent; that should be revisited.
  • Branch naming: no ticket numbers here. The global <type>/BZ-<n>-<desc> convention is Juspay-internal Bitbucket. This OSS repo uses plain <type>/<kebab-description> — feat/cli-support, fix/adjust-body-after-400. Conventional commits; never commit directly to release.
  • ⚠️ docs/development/contributing.md is stale — it teaches interface types (:161) and vitest describe/it (:169-172), both of which contradict enforced rules 7 and 15. Do not cite it as authoritative.

Appendix: verification method

Eight independent auditors, one per claim cluster, each required to cite file:line actually read and to report corrections rather than agree. Findings that could be checked against this machine were checked against installed binaries and live config, not documentation — including a live cursor-agent run that confirmed its env vars are inert, and a opencode models run that confirmed which config file is really loaded.

Claims that did not survive are listed in §2 rather than quietly dropped.