Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/client/cli/commands/claude_settings.py: 0%
307 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
1"""Shared handling of Claude Code's ~/.claude/settings.json.
3`lite up` and `lite autoroute start` patch this file temporarily and restore it on
4exit; `lite configure claude` patches it persistently and records how to undo it.
5All of them need the same merge, and `up` already imports from `auth`, so the
6shared parts live here rather than in any one command module. The credential is
7always a static token in `env.ANTHROPIC_AUTH_TOKEN`: Claude Code's `apiKeyHelper`
8would spawn a `lite` process on every credential refresh, and that process touches
9the keychain, so nothing here writes one; a helper left by an earlier version is
10owned like any other key and stripped.
11"""
13import hashlib
14import json
15import shlex
16import sys
17from collections.abc import Callable, Mapping, Sequence
18from dataclasses import dataclass
19from functools import reduce
20from itertools import chain
21from pathlib import Path
22from types import MappingProxyType
23from typing import Final, TypeAlias
25import click
26from filelock import FileLock
27from packaging.version import InvalidVersion, Version
28from pydantic import BaseModel, ConfigDict, JsonValue, TypeAdapter, ValidationError
30from litellm._version import version as litellm_version
31from litellm.litellm_core_utils.private_json import (
32 commit_staged_json,
33 discard_staged_json,
34 ensure_private_dir,
35 stage_private_json,
36 write_private_bytes,
37)
39from . import statusline_script
40from .cmd_quoting import quote_for_cmd
42ENV_KEY: Final = "env"
43API_KEY_HELPER_KEY: Final = "apiKeyHelper"
44MODEL_KEY: Final = "model"
45STATUS_LINE_KEY: Final = "statusLine"
46ANTHROPIC_BASE_URL_KEY: Final = "ANTHROPIC_BASE_URL"
47ANTHROPIC_AUTH_TOKEN_KEY: Final = "ANTHROPIC_AUTH_TOKEN"
48ANTHROPIC_API_KEY_KEY: Final = "ANTHROPIC_API_KEY"
49ENABLE_TOOL_SEARCH_KEY: Final = "ENABLE_TOOL_SEARCH"
50ENABLE_TOOL_SEARCH_VALUE: Final = "true"
51ENABLE_GATEWAY_MODEL_DISCOVERY_KEY: Final = "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"
52ENABLE_GATEWAY_MODEL_DISCOVERY_VALUE: Final = "1"
53ANTHROPIC_MODEL_KEY: Final = "ANTHROPIC_MODEL"
54ANTHROPIC_DEFAULT_MODEL_ENV_KEYS: Final = (
55 "ANTHROPIC_DEFAULT_SONNET_MODEL",
56 "ANTHROPIC_DEFAULT_HAIKU_MODEL",
57 "ANTHROPIC_DEFAULT_OPUS_MODEL",
58 "ANTHROPIC_DEFAULT_FABLE_MODEL",
59)
60OWNED_ENV_KEYS: Final = (
61 ENABLE_TOOL_SEARCH_KEY,
62 ENABLE_GATEWAY_MODEL_DISCOVERY_KEY,
63 ANTHROPIC_BASE_URL_KEY,
64 ANTHROPIC_AUTH_TOKEN_KEY,
65 ANTHROPIC_API_KEY_KEY,
66 ANTHROPIC_MODEL_KEY,
67)
68OWNED_TOP_LEVEL_KEYS: Final = (API_KEY_HELPER_KEY, MODEL_KEY, STATUS_LINE_KEY)
69OWNED_PATHS: Final = (*(f"{ENV_KEY}.{key}" for key in OWNED_ENV_KEYS), *OWNED_TOP_LEVEL_KEYS)
70_CREDENTIAL_ENV_KEYS: Final = frozenset((ANTHROPIC_API_KEY_KEY, ANTHROPIC_AUTH_TOKEN_KEY))
71_CREDENTIAL_PATHS: Final = (*(f"{ENV_KEY}.{key}" for key in sorted(_CREDENTIAL_ENV_KEYS)), API_KEY_HELPER_KEY)
72_BASE_URL_PATH: Final = f"{ENV_KEY}.{ANTHROPIC_BASE_URL_KEY}"
73_MODEL_PATHS: Final = (MODEL_KEY, f"{ENV_KEY}.{ANTHROPIC_MODEL_KEY}")
74STARTING_MODEL_ROLE: Final = "the /model picker's default row, the model Claude Code starts and resumes on"
76CLAUDE_SETTINGS_PATH: Final = Path.home() / ".claude" / "settings.json"
77CLAUDE_CONFIG_DIR_ENV: Final = "CLAUDE_CONFIG_DIR"
78BACKUP_PATH: Final = Path.home() / ".litellm" / "claude_settings_backup.json"
79AUTOROUTE_BACKUP_PATH: Final = Path.home() / ".litellm" / "autorouter" / "claude_settings_backup.json"
80CONFIGURE_STATE_PATH: Final = Path.home() / ".litellm" / "claude_configure_state.json"
81STATUSLINE_SCRIPT_PATH: Final = Path.home() / ".litellm" / "statusline.py"
82STATUSLINE_VERSION_PREFIX: Final = b"# litellm-statusline-version: "
85@dataclass(frozen=True, slots=True)
86class SettingsFileOwner:
87 """A command that takes temporary ownership of CLAUDE_SETTINGS_PATH and restores it later."""
89 backup_path: Path
90 start_command: str
91 stop_command: str
94SETTINGS_FILE_OWNERS: Final = (
95 SettingsFileOwner(BACKUP_PATH, "lite up", "lite down"),
96 SettingsFileOwner(AUTOROUTE_BACKUP_PATH, "lite autoroute start", "lite autoroute stop"),
97)
99_SETTINGS_ADAPTER: Final = TypeAdapter(dict[str, JsonValue])
102class ClaudeSettingsError(Exception):
103 """Raised for any user-actionable failure while reading or writing Claude Code settings."""
106def claude_settings_path(environ: Mapping[str, str]) -> Path:
107 """The settings.json Claude Code reads: under CLAUDE_CONFIG_DIR when set, else ~/.claude/settings.json."""
108 config_dir: Final = environ.get(CLAUDE_CONFIG_DIR_ENV, "")
109 if not config_dir:
110 return CLAUDE_SETTINGS_PATH
111 return Path(config_dir).expanduser() / "settings.json"
114def _is_default_settings_file(settings_path: Path) -> bool:
115 return settings_path.resolve() == CLAUDE_SETTINGS_PATH.resolve()
118def settings_file_owners(settings_path: Path) -> tuple[SettingsFileOwner, ...]:
119 """The commands whose backups guard settings_path: `lite up` and `lite autoroute start` only ever manage the default file."""
120 return SETTINGS_FILE_OWNERS if _is_default_settings_file(settings_path) else ()
123def configure_state_path(settings_path: Path) -> Path:
124 """The receipt describing settings_path: the default file keeps CONFIGURE_STATE_PATH, and any other file
125 (a CLAUDE_CONFIG_DIR) gets its own beside it, keyed by its resolved path, so two settings files never
126 share one undo record."""
127 if _is_default_settings_file(settings_path):
128 return CONFIGURE_STATE_PATH
129 digest: Final = hashlib.sha256(str(settings_path.resolve()).encode()).hexdigest()
130 return CONFIGURE_STATE_PATH.parent / CONFIGURE_STATE_PATH.stem / f"{digest}.json"
133@dataclass(frozen=True, slots=True)
134class StaticToken:
135 """A long-lived virtual key, written into env.ANTHROPIC_AUTH_TOKEN."""
137 token: str
140@dataclass(frozen=True, slots=True)
141class KeepModel:
142 """Leave the top-level `model` as it is, the user's or an earlier configure's (a re-login)."""
145@dataclass(frozen=True, slots=True)
146class UnpinModel:
147 """Let go of a `model` an earlier configure pinned; one the user set themselves stays."""
150@dataclass(frozen=True, slots=True)
151class StartOn:
152 """Pin `model` and `env.ANTHROPIC_MODEL`: the row Claude Code starts on, and the one a resumed session stays
153 on, since resume otherwise re-sends the transcript's served model, which a raw-model router made a tier
154 model the key may not reach."""
156 model: str
159ModelChoice: TypeAlias = KeepModel | UnpinModel | StartOn
162class OwnedValue(BaseModel):
163 """What one key held at a moment in time; `present=False` is an absent key, not a null one."""
165 model_config = ConfigDict(frozen=True)
167 present: bool
168 value: JsonValue = None
171class ConfigureReceipt(BaseModel):
172 """What `lite configure claude` found and what it owns, keyed by dotted path (`env.X` or a top-level key).
174 Ownership moves only by a write: `written` fingerprints the keys some configure changed, at the
175 value it wrote; a repeat configure refreshes a fingerprint only for a key its merge changed and
176 carries the earlier one otherwise, so a key the user edited in between stops matching and is left
177 alone. `previous` is what each key held before configure took it over; a repeat keeps the earlier
178 snapshot while the key still holds our value and snapshots afresh otherwise, so whatever the
179 repeat displaces is what comes back. `endpoints` is the ANTHROPIC_BASE_URL each credential slot
180 was captured beside, so a credential is only ever put back next to the server it was issued for.
181 No fingerprint is a second copy of a token.
182 """
184 model_config = ConfigDict(frozen=True)
186 file_existed: bool
187 env_present: bool
188 env_was_object: bool
189 previous: Mapping[str, OwnedValue]
190 written: Mapping[str, str]
191 endpoints: Mapping[str, OwnedValue]
194@dataclass(frozen=True, slots=True)
195class WithheldCredential:
196 """A credential left removed: captured beside `endpoint`, while the restored file points elsewhere."""
198 key: str
199 endpoint: str
202@dataclass(frozen=True, slots=True)
203class UnconfigureOutcome:
204 """Keys whose value unconfigure changed back, keys the user changed since and so were left as they
205 are, credentials withheld (the receipt is kept for them, so a later unconfigure can finish once the
206 URL points back), and whether no settings file remains."""
208 restored: tuple[str, ...]
209 kept: tuple[str, ...]
210 withheld: tuple[WithheldCredential, ...] = ()
211 file_removed: bool = False
214@dataclass(frozen=True, slots=True)
215class _Claim:
216 previous: OwnedValue
217 written: str | None
218 endpoint: OwnedValue | None
221def load_json_or_empty(path: Path) -> dict[str, JsonValue]:
222 try:
223 content: Final = path.read_bytes() if path.exists() else b""
224 except OSError as e:
225 raise ClaudeSettingsError(f"Could not read {path}: {e}") from e
226 if not content.strip():
227 return {}
228 try:
229 return _SETTINGS_ADAPTER.validate_json(content)
230 except ValidationError:
231 raise ClaudeSettingsError(
232 f"{path} contains invalid JSON (or its root is not an object); cannot proceed safely."
233 )
236def _env_object(settings: Mapping[str, JsonValue], path: Path) -> Mapping[str, JsonValue]:
237 raw_env: Final = settings.get(ENV_KEY)
238 if raw_env is None:
239 return MappingProxyType({})
240 if not isinstance(raw_env, dict):
241 raise ClaudeSettingsError(
242 f'{path} has a non-object "{ENV_KEY}" value, which this would discard. Fix or remove it, then retry.'
243 )
244 return raw_env
247def refuse_while_owned(settings_path: Path, owners: Sequence[SettingsFileOwner]) -> None:
248 """Refuse while `lite up` or `lite autoroute start` holds a backup it will restore over any write; a
249 purely local check, so commands run it before any login prompt or request."""
250 for owner in owners:
251 if owner.backup_path.exists():
252 raise ClaudeSettingsError(
253 f"`{owner.start_command}` is currently managing {settings_path} (backup at "
254 f"{owner.backup_path}) and will restore it when it stops. "
255 f"Run `{owner.stop_command}` first, then retry."
256 )
259def _write_target(settings_path: Path) -> Path:
260 """Write through a symlinked settings.json rather than replacing the link, which would silently
261 detach a file symlinked into a dotfiles repo."""
262 try:
263 return settings_path.resolve() if settings_path.is_symlink() else settings_path
264 except OSError as e:
265 raise ClaudeSettingsError(f"Could not resolve {settings_path}: {e}") from e
268def write_claude_settings(settings_path: Path, settings: Mapping[str, JsonValue]) -> None:
269 """The one way a settings document lands on disk: staged owner-only beside the target and renamed into
270 place, through a symlink rather than over it. Every writer (`configure`, `up`, `autoroute start` and the
271 restores) may be carrying the credential, so none creates the file under the umask or truncates it."""
272 target: Final = _write_target(settings_path)
273 try:
274 commit_staged_json(stage_private_json(str(target), settings), str(target))
275 except OSError as e:
276 raise ClaudeSettingsError(f"Could not write {settings_path}: {e}") from e
279def _stage(path: Path, document: Mapping[str, object]) -> str:
280 try:
281 return stage_private_json(str(path), document)
282 except OSError as e:
283 raise ClaudeSettingsError(f"Could not write {path}: {e}") from e
286def _land(
287 path: Path,
288 staged: str | None,
289 also_discard: Sequence[str | None] = (),
290 commit: Callable[[str, str], None] = commit_staged_json,
291) -> None:
292 """Commit a staged file to `path`, or remove `path` when nothing is staged for it. The one place a
293 filesystem error becomes a ClaudeSettingsError; on failure the operation's other staged files are
294 discarded, so no temp file holding a token is left behind."""
295 try:
296 if staged is None:
297 path.unlink(missing_ok=True)
298 else:
299 commit(staged, str(path))
300 except OSError as e:
301 for other in also_discard:
302 if other is not None:
303 discard_staged_json(other)
304 raise ClaudeSettingsError(f"Could not {'remove' if staged is None else 'write'} {path}: {e}") from e
307def statusline_command(script_path: Path, platform: str = sys.platform) -> str:
308 """This interpreter, not a bare `python3`: it is the one the apiKeyHelper already depends on."""
309 quote: Final = quote_for_cmd if platform.startswith("win") else shlex.quote
310 return " ".join(quote(token) for token in (sys.executable, str(script_path)))
313def _statusline_version(value: str) -> Version | None:
314 try:
315 return Version(value)
316 except InvalidVersion:
317 return None
320def _installed_statusline_version(target: Path) -> Version | None:
321 try:
322 with target.open("rb") as script:
323 header: Final = script.readline(256)
324 except FileNotFoundError:
325 return None
326 if not header.startswith(STATUSLINE_VERSION_PREFIX):
327 return None
328 try:
329 return _statusline_version(header.removeprefix(STATUSLINE_VERSION_PREFIX).decode("ascii").strip())
330 except UnicodeDecodeError:
331 return None
334def install_statusline_script(
335 script_path: Path | None = None,
336 *,
337 package_version: str = litellm_version,
338 write: Callable[[str, bytes], None] = write_private_bytes,
339) -> str:
340 target: Final = script_path or STATUSLINE_SCRIPT_PATH
341 try:
342 ensure_private_dir(target.parent)
343 bundled_version: Final = _statusline_version(package_version)
344 with FileLock(str(target) + ".lock", timeout=10, mode=0o600):
345 installed_version: Final = _installed_statusline_version(target)
346 if installed_version is not None and (bundled_version is None or installed_version > bundled_version):
347 cli_version: Final = str(bundled_version) if bundled_version is not None else "unknown"
348 click.echo(
349 f"Keeping the status line from LiteLLM {installed_version}; this CLI is {cli_version}. "
350 "Upgrade the CLI to refresh it.",
351 err=True,
352 )
353 return statusline_command(target)
354 source: Final = Path(statusline_script.__file__).read_bytes()
355 header: Final = (
356 STATUSLINE_VERSION_PREFIX + str(bundled_version).encode("ascii") + b"\n"
357 if bundled_version is not None
358 else b""
359 )
360 write(str(target), header + source)
361 except OSError as e:
362 raise ClaudeSettingsError(f"Could not install the status line script at {target}: {e}") from e
363 return statusline_command(target)
366def with_status_line(settings: Mapping[str, JsonValue], command: str) -> Mapping[str, JsonValue]:
367 """Ours is recognised by the script it runs, so a re-install under another interpreter is still ours."""
368 existing: Final = settings.get(STATUS_LINE_KEY)
369 existing_command: Final = existing.get("command") if isinstance(existing, dict) else None
370 ours: Final = existing is None or (isinstance(existing_command, str) and command.split()[-1] in existing_command)
371 if not ours:
372 return settings
373 entry: Final = dict((("type", "command"), ("command", command))) # mutable-ok: JSON document
374 return dict(chain(settings.items(), ((STATUS_LINE_KEY, entry),))) # mutable-ok: JSON document
377def merge_claude_settings(
378 settings: Mapping[str, JsonValue],
379 base_url: str,
380 credential: StaticToken,
381 default_model: str | None = None,
382 tier_model: str | None = None,
383 *,
384 status_line: str | None = None,
385) -> Mapping[str, JsonValue]:
386 """Return a new settings mapping wired to route Claude Code through the proxy.
388 The token lands in env.ANTHROPIC_AUTH_TOKEN; the other credential slots (a stray ANTHROPIC_API_KEY,
389 an apiKeyHelper) are removed, since Claude Code given two credentials may send the wrong one.
390 ENABLE_TOOL_SEARCH and CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY get their defaults only when
391 missing. `default_model` is the top-level `model` and env.ANTHROPIC_MODEL (see StartOn);
392 `tier_model` is `lite autoroute start`'s knob that points every ANTHROPIC_DEFAULT_*_MODEL at one
393 group. Apart from those tier keys, exactly OWNED_PATHS are touched.
394 """
395 raw_env: Final = settings.get(ENV_KEY, {})
396 current_env: Final = raw_env if isinstance(raw_env, dict) else {}
397 env: Final = dict( # mutable-ok: JSON document handed to json.dump, which rejects a read-only mapping
398 chain(
399 (
400 (ENABLE_TOOL_SEARCH_KEY, ENABLE_TOOL_SEARCH_VALUE),
401 (ENABLE_GATEWAY_MODEL_DISCOVERY_KEY, ENABLE_GATEWAY_MODEL_DISCOVERY_VALUE),
402 ),
403 ((key, value) for key, value in current_env.items() if key not in _CREDENTIAL_ENV_KEYS),
404 ((ANTHROPIC_BASE_URL_KEY, base_url.rstrip("/")), (ANTHROPIC_AUTH_TOKEN_KEY, credential.token)),
405 ((ANTHROPIC_MODEL_KEY, default_model),) if default_model is not None else (),
406 ((key, tier_model) for key in ANTHROPIC_DEFAULT_MODEL_ENV_KEYS if tier_model is not None),
407 )
408 )
409 return dict( # mutable-ok: JSON document handed to json.dump, which rejects a read-only mapping
410 chain(
411 (
412 (key, value)
413 for key, value in (with_status_line(settings, status_line) if status_line else settings).items()
414 if key not in (API_KEY_HELPER_KEY, ENV_KEY)
415 ),
416 ((ENV_KEY, env),),
417 ((MODEL_KEY, default_model),) if default_model is not None else (),
418 )
419 )
422def _owned(container: Mapping[str, JsonValue], key: str) -> OwnedValue:
423 return OwnedValue(present=key in container, value=container.get(key))
426def _fingerprint(owned: OwnedValue) -> str:
427 return hashlib.sha256(json.dumps(owned.model_dump(mode="json"), sort_keys=True).encode()).hexdigest()
430def _env(settings: Mapping[str, JsonValue]) -> Mapping[str, JsonValue]:
431 raw_env: Final = settings.get(ENV_KEY)
432 return raw_env if isinstance(raw_env, dict) else MappingProxyType({})
435def _lookup(settings: Mapping[str, JsonValue], path: str) -> OwnedValue:
436 section, _, key = path.rpartition(".")
437 return _owned(_env(settings) if section else settings, key)
440def _with_key(container: Mapping[str, JsonValue], key: str, owned: OwnedValue) -> Mapping[str, JsonValue]:
441 return dict( # mutable-ok: JSON document handed to json.dump, which rejects a read-only mapping
442 chain(((k, v) for k, v in container.items() if k != key), ((key, owned.value),) if owned.present else ())
443 )
446def _with(settings: Mapping[str, JsonValue], path: str, owned: OwnedValue) -> Mapping[str, JsonValue]:
447 """`settings` with the key at `path` set (or removed when `owned` is absent); nothing else changes."""
448 section, _, key = path.rpartition(".")
449 if not section:
450 return _with_key(settings, key, owned)
451 return _with_key(settings, section, OwnedValue(present=True, value=_with_key(_env(settings), key, owned)))
454def _with_all(settings: Mapping[str, JsonValue], updates: Mapping[str, OwnedValue]) -> Mapping[str, JsonValue]:
455 return reduce(lambda acc, item: _with(acc, *item), updates.items(), settings)
458def _ours(settings: Mapping[str, JsonValue], path: str, receipt: ConfigureReceipt) -> bool:
459 """Whether the key still holds what a configure wrote (a key no configure ever changed is never ours)."""
460 return receipt.written.get(path) == _fingerprint(_lookup(settings, path))
463def _claim(
464 path: str,
465 current: Mapping[str, JsonValue],
466 merged: Mapping[str, JsonValue],
467 earlier: ConfigureReceipt | None,
468 url_now: OwnedValue,
469) -> _Claim:
470 """What this configure records for one key; see ConfigureReceipt for the rules."""
471 before, after = _lookup(current, path), _lookup(merged, path)
472 carried: Final = earlier if earlier is not None and _ours(current, path, earlier) else None
473 return _Claim(
474 previous=before if carried is None else carried.previous.get(path, before),
475 written=_fingerprint(after) if before != after else (None if earlier is None else earlier.written.get(path)),
476 endpoint=None
477 if path not in _CREDENTIAL_PATHS
478 else (url_now if carried is None else carried.endpoints.get(path, url_now)),
479 )
482def _receipt(
483 current: Mapping[str, JsonValue],
484 merged: Mapping[str, JsonValue],
485 earlier: ConfigureReceipt | None,
486 file_exists: bool,
487) -> ConfigureReceipt:
488 url_now: Final = _lookup(current, _BASE_URL_PATH)
489 claims: Final = MappingProxyType({path: _claim(path, current, merged, earlier, url_now) for path in OWNED_PATHS})
490 return ConfigureReceipt(
491 file_existed=file_exists if earlier is None else earlier.file_existed,
492 env_present=ENV_KEY in current if earlier is None else earlier.env_present,
493 env_was_object=isinstance(current.get(ENV_KEY), dict) if earlier is None else earlier.env_was_object,
494 previous=MappingProxyType({path: claim.previous for path, claim in claims.items()}),
495 written=MappingProxyType({path: claim.written for path, claim in claims.items() if claim.written is not None}),
496 endpoints=MappingProxyType(
497 {path: claim.endpoint for path, claim in claims.items() if claim.endpoint is not None}
498 ),
499 )
502def read_configure_receipt(state_path: Path) -> ConfigureReceipt | None:
503 if not state_path.exists():
504 return None
505 try:
506 return ConfigureReceipt.model_validate_json(state_path.read_bytes())
507 except (OSError, ValidationError) as e:
508 raise ClaudeSettingsError(
509 f"{state_path} is not a readable `lite configure claude` receipt. "
510 "Remove it and edit Claude Code's settings by hand if they still point at the proxy."
511 ) from e
514def preflight_claude_settings(settings_path: Path) -> None:
515 refuse_while_owned(settings_path, settings_file_owners(settings_path))
516 _env_object(load_json_or_empty(settings_path), settings_path)
517 read_configure_receipt(configure_state_path(settings_path))
520def configure_claude_settings(
521 base_url: str,
522 credential: StaticToken,
523 model: ModelChoice,
524 settings_path: Path,
525 state_path: Path,
526 owners: Sequence[SettingsFileOwner],
527 commit: Callable[[str, str], None] = commit_staged_json,
528 script_path: Path | None = None,
529) -> None:
530 """Persistently route Claude Code through base_url, recording how to undo it.
532 Both files are staged before either is committed, so a full disk or a read-only directory fails
533 before anything changes. The two commits are still two renames: a receipt rename that fails
534 discards the staged settings, and a settings rename that fails after the receipt landed puts the
535 earlier receipt back (or removes the new one), so the receipt on disk never describes settings
536 that were not written. `model`: StartOn pins the starting model, UnpinModel lets go of a pin an
537 earlier configure made (never of the user's own), KeepModel leaves it alone (a re-login). The
538 status line script is installed and registered under `statusLine` unless the user runs their own;
539 the receipt owns that key like any other, so unconfigure removes only ours.
540 """
541 refuse_while_owned(settings_path, owners)
542 current: Final = load_json_or_empty(settings_path)
543 _env_object(current, settings_path)
544 earlier: Final = read_configure_receipt(state_path)
545 unpinned: Final = MappingProxyType(
546 {
547 path: earlier.previous[path]
548 for path in _MODEL_PATHS
549 if isinstance(model, UnpinModel) and earlier is not None and _ours(current, path, earlier)
550 }
551 )
552 existing: Final = _with_all(current, unpinned)
553 merged: Final = merge_claude_settings(
554 existing,
555 base_url,
556 credential,
557 model.model if isinstance(model, StartOn) else None,
558 status_line=install_statusline_script(script_path),
559 )
560 receipt: Final = _receipt(current, merged, earlier, settings_path.exists())
561 target: Final = _write_target(settings_path)
562 try:
563 ensure_private_dir(state_path.parent)
564 except OSError as e:
565 raise ClaudeSettingsError(f"Could not write {state_path}: {e}") from e
566 staged_receipt: Final = _stage(state_path, receipt.model_dump(mode="json"))
567 try:
568 staged_settings: Final = _stage(target, merged)
569 except ClaudeSettingsError:
570 discard_staged_json(staged_receipt)
571 raise
572 _land(state_path, staged_receipt, (staged_settings,), commit)
573 try:
574 _land(target, staged_settings, commit=commit)
575 except ClaudeSettingsError as settings_error:
576 try:
577 _land(state_path, None if earlier is None else _stage(state_path, earlier.model_dump(mode="json")))
578 except ClaudeSettingsError as receipt_error:
579 raise ClaudeSettingsError(
580 f"{settings_error} The receipt at {state_path} now describes settings that were not written and "
581 f"could not be put back either ({receipt_error}); remove it before retrying."
582 ) from settings_error
583 raise
586def _endpoint_text(endpoint: OwnedValue) -> str:
587 if not endpoint.present:
588 return f"no {ANTHROPIC_BASE_URL_KEY} (Anthropic's default endpoint)"
589 return endpoint.value if isinstance(endpoint.value, str) else json.dumps(endpoint.value)
592def unconfigure_claude_settings(
593 settings_path: Path, state_path: Path, owners: Sequence[SettingsFileOwner]
594) -> UnconfigureOutcome:
595 """Undo `lite configure claude`: put back every key still holding what configure wrote, leave the
596 rest alone, and withhold a credential the restored file would send to a different server than it
597 was issued for (the receipt stays, owning only those slots, so a later unconfigure can finish)."""
598 refuse_while_owned(settings_path, owners)
599 receipt: Final = read_configure_receipt(state_path)
600 if receipt is None:
601 raise ClaudeSettingsError(
602 f"Claude Code is not configured by `lite configure claude` (no receipt at {state_path}); nothing to undo."
603 )
604 current: Final = load_json_or_empty(settings_path)
605 _env_object(current, settings_path)
606 ours: Final = tuple(path for path in receipt.written if _ours(current, path, receipt))
607 kept: Final = tuple(path for path in receipt.written if path not in ours and _lookup(current, path).present)
608 put_back: Final = _with_all(current, MappingProxyType({path: receipt.previous[path] for path in ours}))
609 url_after: Final = _lookup(put_back, _BASE_URL_PATH)
610 withheld: Final = tuple(
611 WithheldCredential(path, _endpoint_text(receipt.endpoints[path]))
612 for path in _CREDENTIAL_PATHS
613 if path in ours and receipt.previous[path].present and receipt.endpoints[path] != url_after
614 )
615 absent: Final = OwnedValue(present=False)
616 trimmed: Final = _with_all(put_back, MappingProxyType({item.key: absent for item in withheld}))
617 settings: Final = (
618 trimmed
619 if _env(trimmed) or receipt.env_was_object
620 else _with_key(trimmed, ENV_KEY, OwnedValue(present=receipt.env_present, value=None))
621 )
622 target: Final = _write_target(settings_path)
623 file_removed: Final = not settings and not (receipt.file_existed and target.exists())
624 kept_receipt: Final = (
625 receipt.model_copy(update={"written": {item.key: _fingerprint(absent) for item in withheld}})
626 if withheld
627 else None
628 )
629 staged_settings: Final = None if file_removed else _stage(target, settings)
630 try:
631 staged_receipt: Final = (
632 None if kept_receipt is None else _stage(state_path, kept_receipt.model_dump(mode="json"))
633 )
634 except ClaudeSettingsError:
635 if staged_settings is not None:
636 discard_staged_json(staged_settings)
637 raise
638 _land(target, staged_settings, (staged_receipt,))
639 _land(state_path, staged_receipt)
640 return UnconfigureOutcome(
641 restored=tuple(path for path in ours if _lookup(current, path) != _lookup(settings, path)),
642 kept=kept,
643 withheld=withheld,
644 file_removed=file_removed,
645 )
648__all__ = (
649 "ANTHROPIC_API_KEY_KEY",
650 "ANTHROPIC_AUTH_TOKEN_KEY",
651 "ANTHROPIC_BASE_URL_KEY",
652 "ANTHROPIC_DEFAULT_MODEL_ENV_KEYS",
653 "ANTHROPIC_MODEL_KEY",
654 "API_KEY_HELPER_KEY",
655 "AUTOROUTE_BACKUP_PATH",
656 "BACKUP_PATH",
657 "CLAUDE_CONFIG_DIR_ENV",
658 "CLAUDE_SETTINGS_PATH",
659 "CONFIGURE_STATE_PATH",
660 "ENABLE_GATEWAY_MODEL_DISCOVERY_KEY",
661 "ENABLE_GATEWAY_MODEL_DISCOVERY_VALUE",
662 "ENABLE_TOOL_SEARCH_KEY",
663 "ENABLE_TOOL_SEARCH_VALUE",
664 "ENV_KEY",
665 "MODEL_KEY",
666 "OWNED_ENV_KEYS",
667 "OWNED_PATHS",
668 "OWNED_TOP_LEVEL_KEYS",
669 "SETTINGS_FILE_OWNERS",
670 "STARTING_MODEL_ROLE",
671 "STATUSLINE_SCRIPT_PATH",
672 "STATUS_LINE_KEY",
673 "ClaudeSettingsError",
674 "ConfigureReceipt",
675 "KeepModel",
676 "ModelChoice",
677 "OwnedValue",
678 "SettingsFileOwner",
679 "StartOn",
680 "StaticToken",
681 "UnconfigureOutcome",
682 "UnpinModel",
683 "WithheldCredential",
684 "claude_settings_path",
685 "configure_claude_settings",
686 "configure_state_path",
687 "load_json_or_empty",
688 "merge_claude_settings",
689 "read_configure_receipt",
690 "refuse_while_owned",
691 "settings_file_owners",
692 "unconfigure_claude_settings",
693 "write_claude_settings",
694)