From 04f5dedf69aa641639e8afd2dc03260e813bf9ac Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Thu, 13 Aug 2026 17:23:03 -0700 Subject: [PATCH] feat(cli): make the hidden `lite` command list configurable (#36816) * fix(cli): hide codex and opencode from the lite command listings They stay registered and invokable, so existing `lite codex` users keep working; they just no longer show up in `lite --help` or the interactive shell's command list. * feat(cli): make the hidden lite command list configurable codex and opencode are supported, so hardcoding them as hidden was wrong. Let deployments curate their own listing with `lite config set hidden_commands codex,opencode` instead; nothing is hidden by default and hidden commands stay invokable. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/client/cli/README.md | 11 +++ litellm/proxy/client/cli/commands/agents.py | 4 +- litellm/proxy/client/cli/commands/config.py | 59 +++++++++++--- litellm/proxy/client/cli/interface.py | 8 +- litellm/proxy/client/cli/main.py | 18 ++++- .../proxy/client/cli/test_config_commands.py | 81 +++++++++++++++++++ 6 files changed, 164 insertions(+), 17 deletions(-) diff --git a/litellm/proxy/client/cli/README.md b/litellm/proxy/client/cli/README.md index de9d38963c1..72ed67728d8 100644 --- a/litellm/proxy/client/cli/README.md +++ b/litellm/proxy/client/cli/README.md @@ -36,6 +36,17 @@ The base URL is resolved in this order of precedence: 3. `base_url` from `~/.litellm/config.json` 4. `http://localhost:4000` +### Hiding commands from the listings + +Deployments that hand `lite` to end users often want to advertise only part of it. Store the commands to keep out of the listings, comma separated: + +```bash +lite config set hidden_commands codex,opencode +lite config unset hidden_commands # list everything again +``` + +Hidden commands drop out of both `lite --help` and the interactive shell's "Available commands" block, and stay runnable so existing scripts keep working + ## Global Options - `--version`, `-v`: Print the LiteLLM Proxy client and server version and exit. diff --git a/litellm/proxy/client/cli/commands/agents.py b/litellm/proxy/client/cli/commands/agents.py index 945f942162a..ed2bf2be03d 100644 --- a/litellm/proxy/client/cli/commands/agents.py +++ b/litellm/proxy/client/cli/commands/agents.py @@ -366,9 +366,9 @@ def _make_agent_command(binary: str, display_name: str) -> click.Command: return _command -def agent_commands() -> list[click.Command]: +def agent_commands() -> tuple[click.Command, ...]: """Build one top-level command per known agent, e.g. `lite claude`.""" - return [_make_agent_command(binary, name) for binary, (name, _profiles) in _KNOWN_AGENTS.items()] + return tuple(_make_agent_command(binary, name) for binary, (name, _profiles) in _KNOWN_AGENTS.items()) __all__ = [ diff --git a/litellm/proxy/client/cli/commands/config.py b/litellm/proxy/client/cli/commands/config.py index 8f1fcac740a..19dd407ba19 100644 --- a/litellm/proxy/client/cli/commands/config.py +++ b/litellm/proxy/client/cli/commands/config.py @@ -1,8 +1,9 @@ import json import os import sys -from collections.abc import Mapping +from collections.abc import Callable, Mapping from pathlib import Path +from types import MappingProxyType from typing import Final from urllib.parse import urlparse @@ -11,7 +12,7 @@ from pydantic import TypeAdapter from .private_json import write_private_json -ALLOWED_CONFIG_KEYS: Final[tuple[str, ...]] = ("base_url",) +HIDDEN_COMMANDS_KEY: Final = "hidden_commands" _config_adapter: Final[TypeAdapter[Mapping[str, str]]] = TypeAdapter(Mapping[str, str]) @@ -49,6 +50,48 @@ def get_config_value(key: str) -> str | None: return load_config().get(key) +def parse_hidden_commands(raw: str | None) -> frozenset[str]: + """Split a stored `hidden_commands` value, e.g. "codex, opencode".""" + return frozenset(name.strip() for name in (raw or "").split(",") if name.strip()) + + +def hidden_command_names() -> frozenset[str]: + """Top-level commands the operator chose to keep out of `lite`'s listings.""" + return parse_hidden_commands(get_config_value(HIDDEN_COMMANDS_KEY)) + + +def _normalize_base_url(value: str) -> str: + parsed: Final = urlparse(value) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + raise click.UsageError("base_url must be a full http:// or https:// URL including a host") + if "?" in value or "#" in value: + raise click.UsageError("base_url must not include a query string or fragment") + return value.rstrip("/") + + +def _normalize_hidden_commands(value: str) -> str: + names: Final = parse_hidden_commands(value) + if not names: + raise click.UsageError( + f"{HIDDEN_COMMANDS_KEY} must be a comma-separated list of command names, e.g. " + f"`lite config set {HIDDEN_COMMANDS_KEY} codex,opencode`. To list everything again, " + f"run `lite config unset {HIDDEN_COMMANDS_KEY}`" + ) + if any(" " in name for name in names): + raise click.UsageError(f"{HIDDEN_COMMANDS_KEY} entries must be single command names, without spaces") + return ",".join(sorted(names)) + + +_NORMALIZERS: Final[Mapping[str, Callable[[str], str]]] = MappingProxyType( + { + "base_url": _normalize_base_url, + HIDDEN_COMMANDS_KEY: _normalize_hidden_commands, + } +) + +ALLOWED_CONFIG_KEYS: Final[tuple[str, ...]] = tuple(_NORMALIZERS) + + @click.group(name="config") def config_commands() -> None: """Manage persistent CLI configuration (~/.litellm/config.json)""" @@ -59,17 +102,11 @@ def config_commands() -> None: @click.argument("value") def set_config(key: str, value: str) -> None: """Set a config KEY to VALUE (e.g. `lite config set base_url https://your-proxy.example.com`)""" - if key not in ALLOWED_CONFIG_KEYS: + normalizer: Final = _NORMALIZERS.get(key) + if normalizer is None: raise click.UsageError(f"Unknown config key '{key}'. Allowed keys: {', '.join(ALLOWED_CONFIG_KEYS)}") - if key == "base_url": - parsed: Final = urlparse(value) - if parsed.scheme not in ("http", "https") or not parsed.netloc: - raise click.UsageError("base_url must be a full http:// or https:// URL including a host") - if "?" in value or "#" in value: - raise click.UsageError("base_url must not include a query string or fragment") - - normalized_value: Final = value.rstrip("/") + normalized_value: Final = normalizer(value) save_config({**load_config(), key: normalized_value}) click.echo(f"Set {key} = {normalized_value} in {get_config_file_path()}") diff --git a/litellm/proxy/client/cli/interface.py b/litellm/proxy/client/cli/interface.py index 84862bb4a55..b4f44240adb 100644 --- a/litellm/proxy/client/cli/interface.py +++ b/litellm/proxy/client/cli/interface.py @@ -74,8 +74,9 @@ def styled_prompt(): def show_commands(): - """Display available commands.""" + """Display available commands, minus any the operator chose to hide.""" from .commands.agents import agent_commands + from .commands.config import hidden_command_names commands = [ ("login", "Authenticate with the LiteLLM proxy server"), @@ -96,9 +97,12 @@ def show_commands(): ("quit", "Exit the interactive session"), ] + hidden: Final = hidden_command_names() + click.echo("Available commands:") for cmd, description in commands: - click.echo(f" {cmd:<20} {description}") + if cmd not in hidden: + click.echo(f" {cmd:<20} {description}") click.echo() diff --git a/litellm/proxy/client/cli/main.py b/litellm/proxy/client/cli/main.py index 95d4a751226..3a289736c66 100644 --- a/litellm/proxy/client/cli/main.py +++ b/litellm/proxy/client/cli/main.py @@ -12,7 +12,7 @@ from .commands.agents import agent_commands from .commands.auth import auth_group, get_stored_api_key, login, logout, whoami from .commands.autoroute.commands import autoroute_group from .commands.chat import chat -from .commands.config import config_commands, get_config_value +from .commands.config import config_commands, get_config_value, hidden_command_names from .commands.credentials import credentials from .commands.encryption import encryption from .commands.http import http @@ -43,7 +43,21 @@ def print_version(base_url: str, api_key: str | None): click.echo(f"Could not retrieve server version: {e}") -@click.group(invoke_without_command=True) +class HideConfiguredCommandsGroup(click.Group): + """Group that omits operator-hidden commands from listings, still running them. + + Deployments hand `lite` to users who should only see a curated subset of + commands (`lite config set hidden_commands codex,opencode`). Filtering the + listing rather than dropping the commands keeps anyone's existing scripts + working. + """ + + def list_commands(self, ctx: click.Context) -> list[str]: + hidden: Final = hidden_command_names() + return [name for name in super().list_commands(ctx) if name not in hidden] + + +@click.group(cls=HideConfiguredCommandsGroup, invoke_without_command=True) @click.option( "--version", "-v", diff --git a/tests/test_litellm/proxy/client/cli/test_config_commands.py b/tests/test_litellm/proxy/client/cli/test_config_commands.py index 698d6188768..d81ee6bd2b1 100644 --- a/tests/test_litellm/proxy/client/cli/test_config_commands.py +++ b/tests/test_litellm/proxy/client/cli/test_config_commands.py @@ -3,6 +3,7 @@ import os import stat import sys from pathlib import Path +from unittest.mock import patch import pytest from click.testing import CliRunner @@ -18,6 +19,7 @@ from litellm.proxy.client.cli.commands.config import ( save_config, ) from litellm.proxy.client.cli.commands.private_json import write_private_json +from litellm.proxy.client.cli.interface import show_commands @pytest.fixture @@ -179,6 +181,85 @@ class TestConfigUnset: assert "not set" in result.output.lower() +class TestHiddenCommands: + """`hidden_commands` lets a deployment curate what `lite` advertises. + + Two listings exist and both must honor it: click's own `--help` table and the + hand-rolled block the interactive shell prints. + """ + + def test_nothing_is_hidden_by_default(self, cli_runner, isolated_home): + result = cli_runner.invoke(cli, ["--help"]) + + assert result.exit_code == 0, result.output + assert "codex" in result.output + assert "opencode" in result.output + + def test_configured_commands_drop_out_of_help(self, cli_runner, isolated_home): + assert cli_runner.invoke(cli, ["config", "set", "hidden_commands", "codex,opencode"]).exit_code == 0 + + result = cli_runner.invoke(cli, ["--help"]) + + assert result.exit_code == 0, result.output + assert "claude" in result.output + assert "codex" not in result.output + assert "opencode" not in result.output + + def test_configured_commands_drop_out_of_interactive_listing(self, capsys, isolated_home): + save_config({"hidden_commands": "codex,keys"}) + + show_commands() + listing = capsys.readouterr().out + + assert "claude" in listing + assert "codex" not in listing + assert "keys" not in listing + assert "teams" in listing + + def test_hidden_commands_are_still_invokable(self, cli_runner, isolated_home): + """Hiding is about the listing only; anyone already scripting the command keeps working.""" + save_config({"hidden_commands": "codex"}) + + with patch("litellm.proxy.client.cli.commands.agents.run_agent") as run_agent_mock: + result = cli_runner.invoke( + cli, + ["--base-url", "http://localhost:4000", "--api-key", "sk-key", "codex", "exec", "do a thing"], + ) + + assert result.exit_code == 0, result.output + _base_url, _api_key, command = run_agent_mock.call_args.args + assert list(command) == ["codex", "exec", "do a thing"] + + def test_unset_brings_the_commands_back(self, cli_runner, isolated_home): + assert cli_runner.invoke(cli, ["config", "set", "hidden_commands", "codex"]).exit_code == 0 + assert cli_runner.invoke(cli, ["config", "unset", "hidden_commands"]).exit_code == 0 + + assert "codex" in cli_runner.invoke(cli, ["--help"]).output + + def test_set_normalizes_whitespace_and_ordering(self, cli_runner, isolated_home): + result = cli_runner.invoke(cli, ["config", "set", "hidden_commands", " opencode , codex ,"]) + + assert result.exit_code == 0, result.output + assert json.loads(_config_path(isolated_home).read_text()) == {"hidden_commands": "codex,opencode"} + + @pytest.mark.parametrize("value", ["", " ", ",", " , "]) + def test_set_empty_list_rejected(self, cli_runner, isolated_home, value): + """An empty value would silently hide nothing; point users at `config unset` instead.""" + result = cli_runner.invoke(cli, ["config", "set", "hidden_commands", value]) + + assert result.exit_code != 0 + assert "unset" in result.output + assert not _config_path(isolated_home).exists() + + def test_set_space_separated_list_rejected(self, cli_runner, isolated_home): + """`lite config set hidden_commands "codex opencode"` would hide neither.""" + result = cli_runner.invoke(cli, ["config", "set", "hidden_commands", "codex opencode"]) + + assert result.exit_code != 0 + assert "without spaces" in result.output + assert not _config_path(isolated_home).exists() + + class TestConfigHelpers: def test_get_config_file_path_under_home(self, isolated_home): assert get_config_file_path() == str(isolated_home / ".litellm" / "config.json")