Skip to content

feat(claude-code): route via ~/.claude/settings.json instead of a PATH shim#74

Merged
stuffbucket merged 1 commit into
mainfrom
feat/claude-code-settings-pivot
Jun 8, 2026
Merged

feat(claude-code): route via ~/.claude/settings.json instead of a PATH shim#74
stuffbucket merged 1 commit into
mainfrom
feat/claude-code-settings-pivot

Conversation

@stuffbucket

Copy link
Copy Markdown
Owner

Replaces the Claude Code PATH-shim with Claude Code's native config — writing env.ANTHROPIC_BASE_URL into ~/.claude/settings.json. This is the approach LiteLLM / Portkey use; it's read fresh on every claude invocation (no PATH race), and it sidesteps every shim failure mode we hit (version pinning, PATH ordering losing the race, process-name identity guards that never matched).

Why

Research (fanned out to subagents) confirmed: no one ships a binary-masquerade PATH shim — every gateway just sets ANTHROPIC_BASE_URL or writes the config. Claude Code natively supports ~/.claude/settings.json with an env block. The shim was complex machinery for a problem a one-file write solves.

What changed

New src/lib/claude-code-settings.ts — ownership-guarded, atomic writer modeled on claude-desktop-config.ts:

  • Owns exactly one key: ANTHROPIC_BASE_URL (nested under settings.env). Never touches ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN or any other setting.
  • Ownership guard: refuses to overwrite a foreign base URL (user-set or another gateway), and refuses to remove one on revert.
  • Atomic O_EXCL + 0600 write; honors CLAUDE_CONFIG_DIR.

Apps card: claude-code converts from shimmableconfig (now a near-clone of Claude Desktop). enabledisProxyBaseUrlConfigured(); toggle enable→applyProxyBaseUrl, disable→revertProxyBaseUrl. A foreign base URL surfaces as a new conflict: "foreign-base-url" field. Detection is kept (drives "is claude installed?" → ready/not-installed + install hint); the per-binary version picker (installs[].active, selectedPath, /claude-code/select) is gone — claude reads its own settings regardless of which binary runs.

Shim apparatus removed from claude-cli-detect.ts (−408 lines) plus tests/apps-shim.test.ts. Kept detectClaudeInstalls + SHIM_MARKER + fileStartsWithContains so detection still skips a leftover legacy shim rather than mislabeling it an install.

uninstall now reverts the settings.json base URL (ownership-guarded) instead of deleting a shim; the first-launch installer PATH-block cleanup is unchanged.

config: drop apps.claudeCode.selectedPath (zod nested-object strip drops it silently from old configs — no migration needed). settings-types: retire the shimmable kind + ClaudeCodeSelectRequest; AppInstall loses active; AppEntry gains conflict.

Verification

New claude-code-settings.test.ts (29 cases: write/preserve-sibling-env, ownership guard, idempotency, revert variants, foreign-URL-left-intact, 0600, CLAUDE_CONFIG_DIR). apps-route + uninstall reworked. 745 pass / 0 fail, check:fast + knip clean, shell builds.

Follow-ups (NOT in this PR)

  1. Frontend (shell/src/features/apps) still references the retired shimmable kind / /select endpoint at runtime — it compiles (contract types are local) but needs the Apps card reworked to render Claude Code like Claude Desktop (drop the version picker, surface conflict).
  2. Lifecycle reconciler — the failure plan we designed: the Tauri shell owns "block present ⟺ intent(on) ∧ proxy-reachable", writing on confirmed-up (/status) and removing on proxy-down, via a shared maximal configure-claude-code [--revert] CLI subcommand so it works even when the server is down.

…H shim

Replace the Claude Code PATH-shim with Claude Code's native config:
write `env.ANTHROPIC_BASE_URL` into ~/.claude/settings.json. This is the
approach LiteLLM/Portkey use, it's read fresh on every `claude`
invocation (no PATH race), and it sidesteps every shim failure mode
(version pinning, PATH ordering, process-name identity guards).

New: src/lib/claude-code-settings.ts — an ownership-guarded, atomic
writer modeled on claude-desktop-config.ts. It owns EXACTLY ONE key,
`ANTHROPIC_BASE_URL` (nested under settings.env); it never touches
ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN or any other env var or setting.
The ownership guard refuses to overwrite a foreign base URL (user-set or
another gateway) and refuses to remove one on revert. Atomic O_EXCL +
0600 write; honors CLAUDE_CONFIG_DIR.

Apps card: claude-code converts from "shimmable" to "config" (now a
near-clone of Claude Desktop). `enabled` derives from
isProxyBaseUrlConfigured(); toggle enable→applyProxyBaseUrl,
disable→revertProxyBaseUrl. A foreign base URL surfaces as a new
`conflict: "foreign-base-url"` field on the card. Detection is KEPT
(drives "is claude installed?" → ready/not-installed + the install hint),
but the per-binary version picker (installs[].active, selectedPath, the
/claude-code/select route) is gone — claude reads its own settings
regardless of which binary runs.

Removed the entire shim apparatus from claude-cli-detect.ts (shim build/
install/remove, the precmd PATH-block machinery, getShimPath, etc.) plus
tests/apps-shim.test.ts. KEPT detectClaudeInstalls + SHIM_MARKER +
fileStartsWithContains: detection still skips a leftover legacy shim so
it isn't mislabeled an install. uninstall now reverts the settings.json
base URL (ownership-guarded) instead of deleting a shim; the first-launch
installer PATH-block cleanup is unchanged.

config: drop apps.claudeCode.selectedPath (zod nested-object strip drops
it silently from old configs — no migration needed). settings-types:
retire the "shimmable" kind and ClaudeCodeSelectRequest; AppInstall loses
`active`; AppEntry gains `conflict`.

Tests: new claude-code-settings.test.ts (29); apps-route + uninstall
reworked for the settings.json model. Full suite 745 pass, check:fast +
knip clean.

Note: the Apps frontend (shell/src/features/apps) still references the
retired shimmable kind / select endpoint and needs a follow-up change.
@stuffbucket
stuffbucket merged commit cf0f578 into main Jun 8, 2026
4 checks passed
@app-repoman app-repoman Bot mentioned this pull request Jun 8, 2026
stuffbucket added a commit that referenced this pull request Jun 8, 2026
The settings.json pivot (#74) retired the per-binary shim model, but the
Tauri shell's Apps card still spoke the old contract — kind:"shimmable",
install.active, and a POST /claude-code/select call that now 404s — so the
Claude Code toggle was wired to a backend that no longer answered.

- api.ts: drop AppKind "shimmable" and AppInstall.active; remove the
  claude-code-select endpoint and the toggle's `path` param; add the
  `conflict` field to AppEntry.
- AppCard.tsx: render Claude Code as a config card like Claude Desktop
  (install path in mono, single On/Off switch, no version radio picker).
  Add a conflict surface: when enabling is refused because the app already
  carries a base URL we don't own, show a plain-language callout naming the
  cause and the one fix — so the toggle snapping back is never a mystery
  dead-end.
- useApps.ts / AppsPanel.tsx: drop selectClaudeCode and the path-threaded
  toggle.
- styles.css: replace the dead .app-versions radio styles with the
  .app-card__conflict callout (destructive-tinted stripe, matches the
  requirement-callout idiom).

Verified live against the new backend: list renders both Claude apps as
config cards, the ownership guard preserves a foreign base URL on enable,
and the conflict callout renders per the design system. Shell typechecks
clean; settings UI builds.
stuffbucket added a commit that referenced this pull request Jun 8, 2026
)

* feat(apps): lifecycle reconciler for Claude Code routing

Decouple the routing *intent* (config.apps.claudeCode.enabled) from the
on-disk *effect* (env.ANTHROPIC_BASE_URL in ~/.claude/settings.json) and
reconcile at every proxy lifecycle edge, so a dead 127.0.0.1:4141 is never
left stranded in Claude Code's config when maximal isn't running.

- Boot: re-apply the base URL if routing is intended (self-heals a URL a
  prior crash/force-kill left over a dead proxy).
- Graceful shutdown (SIGTERM/SIGINT/parent-death): remove the base URL so
  `claude` isn't stranded; intent flag persists, so next boot re-applies.
- Crash (sidecar non-zero exit): the proxy can't revert itself, so the
  Tauri shell runs `maximal configure-claude-code --revert` on its behalf.

All paths are ownership-guarded (a foreign ANTHROPIC_BASE_URL is never
clobbered or removed) and only touch that one key — sibling env vars
(ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN) are untouched. Each death mode
has exactly one reverter, so no double-revert races.

New shared CLI subcommand `configure-claude-code [--revert]` mirrors
configure-claude-desktop and works while the server is down.

Reconcile helpers take injectable intent/path params (no mock.module) per
CLAUDE.md. 16 new tests; full suite + cargo check green.

* test(apps): fix configure-claude-code mock-bleed via injectable filePath

apps-route.test.ts mocks claude-code-settings with path-defaulting wrappers;
Bun persists that mock forward, so in CI's file ordering the subcommand's
no-arg applyProxyBaseUrl() hit the mocked default path instead of the test's
CLAUDE_CONFIG_DIR. Thread an injectable filePath through runConfigureClaudeCode
and pass it explicitly in the test (CLAUDE.md: prefer injectable options over
mock.module). Verified by running apps-route before configure-claude-code
locally, reproducing the CI ordering.

* feat(shell): rework Apps card for config-kind Claude Code routing

The settings.json pivot (#74) retired the per-binary shim model, but the
Tauri shell's Apps card still spoke the old contract — kind:"shimmable",
install.active, and a POST /claude-code/select call that now 404s — so the
Claude Code toggle was wired to a backend that no longer answered.

- api.ts: drop AppKind "shimmable" and AppInstall.active; remove the
  claude-code-select endpoint and the toggle's `path` param; add the
  `conflict` field to AppEntry.
- AppCard.tsx: render Claude Code as a config card like Claude Desktop
  (install path in mono, single On/Off switch, no version radio picker).
  Add a conflict surface: when enabling is refused because the app already
  carries a base URL we don't own, show a plain-language callout naming the
  cause and the one fix — so the toggle snapping back is never a mystery
  dead-end.
- useApps.ts / AppsPanel.tsx: drop selectClaudeCode and the path-threaded
  toggle.
- styles.css: replace the dead .app-versions radio styles with the
  .app-card__conflict callout (destructive-tinted stripe, matches the
  requirement-callout idiom).

Verified live against the new backend: list renders both Claude apps as
config cards, the ownership guard preserves a foreign base URL on enable,
and the conflict callout renders per the design system. Shell typechecks
clean; settings UI builds.
@stuffbucket
stuffbucket deleted the feat/claude-code-settings-pivot branch July 13, 2026 05:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant