From b3f2de058be47e4613b8e6ad27039af3bb58c7a1 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:19:56 -0700 Subject: [PATCH] fix(cli): only guard the default settings.json with the lite up and autoroute backups on login --config-claude --- litellm/proxy/client/cli/commands/auth.py | 4 +- .../client/cli/commands/claude_settings.py | 6 +++ .../proxy/client/cli/test_auth_commands.py | 37 ++++++++++++++++--- 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/litellm/proxy/client/cli/commands/auth.py b/litellm/proxy/client/cli/commands/auth.py index 8e766d94732..a0bf9254bbc 100644 --- a/litellm/proxy/client/cli/commands/auth.py +++ b/litellm/proxy/client/cli/commands/auth.py @@ -41,9 +41,9 @@ from litellm.litellm_core_utils.cli_token_utils import ( ) from .claude_settings import ( - SETTINGS_FILE_OWNERS, ClaudeSettingsError, claude_settings_path, + settings_file_owners, write_claude_settings, ) from .pkce_login import ( @@ -782,7 +782,7 @@ def _configure_claude_code(base_url: str) -> None: """Point Claude Code at base_url by patching the settings.json it reads.""" settings_path: Final = claude_settings_path(os.environ) try: - write_claude_settings(base_url, settings_path, SETTINGS_FILE_OWNERS) + write_claude_settings(base_url, settings_path, settings_file_owners(settings_path)) except ClaudeSettingsError as e: raise click.ClickException(f"Logged in, but could not configure Claude Code: {e}") click.echo(f"\nConfigured Claude Code: {settings_path} now routes through {base_url.rstrip('/')}.") diff --git a/litellm/proxy/client/cli/commands/claude_settings.py b/litellm/proxy/client/cli/commands/claude_settings.py index 524edcb899a..7e96d2a3a08 100644 --- a/litellm/proxy/client/cli/commands/claude_settings.py +++ b/litellm/proxy/client/cli/commands/claude_settings.py @@ -64,6 +64,11 @@ def claude_settings_path(environ: Mapping[str, str]) -> Path: return Path(config_dir).expanduser() / "settings.json" +def settings_file_owners(settings_path: Path) -> tuple[SettingsFileOwner, ...]: + """The commands whose backups guard settings_path: `lite up` and `lite autoroute up` only ever manage the default file.""" + return SETTINGS_FILE_OWNERS if settings_path == CLAUDE_SETTINGS_PATH else () + + def load_json_or_empty(path: Path) -> dict[str, JsonValue]: try: content: Final = path.read_bytes() if path.exists() else b"" @@ -197,5 +202,6 @@ __all__ = ( "load_json_or_empty", "merge_claude_settings", "resolve_api_key_helper", + "settings_file_owners", "write_claude_settings", ) diff --git a/tests/test_litellm/proxy/client/cli/test_auth_commands.py b/tests/test_litellm/proxy/client/cli/test_auth_commands.py index 225396d78f6..9c3c02a5e85 100644 --- a/tests/test_litellm/proxy/client/cli/test_auth_commands.py +++ b/tests/test_litellm/proxy/client/cli/test_auth_commands.py @@ -1398,9 +1398,10 @@ class TestLoginConfigClaude: def setup_method(self): self.runner = CliRunner() - def _run_login(self, tmp_path, args, base_url="https://test.example.com"): + def _run_login(self, tmp_path, args, base_url="https://test.example.com", *, config_dir_env=None): settings_path = tmp_path / "claude" / "settings.json" backup_path = tmp_path / "claude_settings_backup.json" + env = {"CLAUDE_CONFIG_DIR": str(settings_path.parent)} if config_dir_env is None else config_dir_env poll_response = Mock() poll_response.status_code = 200 poll_response.json.return_value = { @@ -1417,17 +1418,19 @@ class TestLoginConfigClaude: patch("litellm.proxy.client.cli.commands.auth.save_cli_token"), patch("litellm.proxy.client.cli.interface.show_commands"), patch( - "litellm.proxy.client.cli.commands.auth.SETTINGS_FILE_OWNERS", + "litellm.proxy.client.cli.commands.claude_settings.SETTINGS_FILE_OWNERS", (SettingsFileOwner(backup_path, "lite up", "lite down"),), ), + patch( + "litellm.proxy.client.cli.commands.claude_settings.CLAUDE_SETTINGS_PATH", + tmp_path / "default-home" / ".claude" / "settings.json", + ), patch( "litellm.proxy.client.cli.commands.claude_settings.shutil.which", return_value="/usr/local/bin/lite", ), ): - result = self.runner.invoke( - login, args, obj={"base_url": base_url}, env={"CLAUDE_CONFIG_DIR": str(settings_path.parent)} - ) + result = self.runner.invoke(login, args, obj={"base_url": base_url}, env=env) return result, settings_path, backup_path def test_default_login_does_not_touch_claude_settings(self, tmp_path): @@ -1460,6 +1463,30 @@ class TestLoginConfigClaude: assert written["theme"] == "dark" assert written["env"]["KEEP"] == "me" + def test_flag_refuses_while_lite_up_holds_the_default_settings_file(self, tmp_path): + default_settings_path = tmp_path / "default-home" / ".claude" / "settings.json" + (tmp_path / "claude_settings_backup.json").write_text("{}") + + result, _settings_path, _backup_path = self._run_login( + tmp_path, ["--config-claude"], config_dir_env={"CLAUDE_CONFIG_DIR": ""} + ) + + assert result.exit_code != 0 + assert "Login successful!" in result.output + assert "`lite up` is currently managing" in result.output + assert "Run `lite down` first" in result.output + assert not default_settings_path.exists() + + def test_flag_writes_an_alternate_config_dir_even_while_lite_up_holds_the_default_file(self, tmp_path): + (tmp_path / "claude_settings_backup.json").write_text("{}") + + result, settings_path, _backup_path = self._run_login(tmp_path, ["--config-claude"]) + + assert result.exit_code == 0, result.output + written = json.loads(settings_path.read_text()) + assert written["apiKeyHelper"] == "/usr/local/bin/lite --base-url https://test.example.com auth print-token" + assert f"Configured Claude Code: {settings_path} now routes through https://test.example.com." in result.output + def test_settings_failure_is_reported_without_claiming_login_failed(self, tmp_path): settings_path = tmp_path / "claude" / "settings.json" settings_path.parent.mkdir(parents=True)