diff --git a/litellm/proxy/client/cli/commands/agents.py b/litellm/proxy/client/cli/commands/agents.py index 36a3cfdc810..f39ffb3e864 100644 --- a/litellm/proxy/client/cli/commands/agents.py +++ b/litellm/proxy/client/cli/commands/agents.py @@ -145,6 +145,26 @@ def _exec(path: str, args: Sequence[str], env: Mapping[str, str]) -> None: os.execvpe(path, list(args), dict(env)) +def _restore_controlling_terminal() -> None: + """Reattach the controlling terminal to stdin before handing off to the agent. + + Completing the browser SSO login can leave stdin detached from the terminal, + which makes a TUI agent like Claude Code start in non-interactive mode and + exit immediately. Reopening /dev/tty onto fd 0 gives the agent a live + terminal; when stdin is still a tty (no login happened) this is a no-op. + """ + if sys.stdin.isatty(): + return + try: + fd = os.open("/dev/tty", os.O_RDONLY) + except OSError: + return + try: + os.dup2(fd, 0) + finally: + os.close(fd) + + def run_agent( base_url: str, api_key: str, @@ -155,11 +175,13 @@ def run_agent( which: Callable[[str], Optional[str]] = shutil.which, verify: Callable[[str, str], None] = verify_proxy_key, launcher: Callable[[str, Sequence[str], Mapping[str, str]], None] = _exec, + reattach_terminal: Optional[Callable[[], None]] = None, ) -> None: """Validate, wire the environment, and hand off to the agent. On success this replaces the current process and never returns. Raises AgentRunError for missing binaries, an unreachable proxy, or a rejected key. + reattach_terminal, when given, runs just before handoff to restore stdin. """ if not command: raise AgentRunError("Nothing to run.") @@ -181,6 +203,8 @@ def run_agent( profiles, ) extra_args = agent_launch_args(command[0], base_url) + if reattach_terminal is not None: + reattach_terminal() launcher(binary, [command[0], *extra_args, *command[1:]], env) @@ -217,6 +241,7 @@ def _launch( ctx: click.Context, binary: str, args: Sequence[str], *, skip_verify: bool ) -> None: base_url = ctx.obj["base_url"] + started_interactive = _is_interactive() api_key = _resolve_api_key(ctx) display_name, _ = agent_profile(binary) @@ -225,7 +250,15 @@ def _launch( ) try: - run_agent(base_url, api_key, [binary, *args], skip_verify=skip_verify) + run_agent( + base_url, + api_key, + [binary, *args], + skip_verify=skip_verify, + reattach_terminal=( + _restore_controlling_terminal if started_interactive else None + ), + ) except AgentRunError as e: raise click.ClickException(str(e)) diff --git a/pyproject.toml b/pyproject.toml index 1a1c29727a6..019769d8c72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -133,6 +133,7 @@ proxy-runtime = [ [project.scripts] litellm = "litellm:run_server" lite = "litellm.proxy.client.cli:cli" +litellm-proxy = "litellm.proxy.client.cli:cli" [dependency-groups] dev = [ diff --git a/tests/test_litellm/proxy/client/cli/test_agents.py b/tests/test_litellm/proxy/client/cli/test_agents.py index ab8e7e50cae..afd1696a89f 100644 --- a/tests/test_litellm/proxy/client/cli/test_agents.py +++ b/tests/test_litellm/proxy/client/cli/test_agents.py @@ -286,6 +286,33 @@ class TestRunAgent: with pytest.raises(AgentRunError): run_agent("http://localhost:4000", "sk-key", []) + def test_reattach_terminal_runs_just_before_launch(self): + order = [] + run_agent( + "http://localhost:4000", + "sk-key", + ["claude"], + skip_verify=True, + base_env={}, + which=lambda name: "/usr/local/bin/claude", + launcher=lambda *a: order.append("launch"), + reattach_terminal=lambda: order.append("reattach"), + ) + assert order == ["reattach", "launch"] + + def test_no_reattach_terminal_by_default(self): + order = [] + run_agent( + "http://localhost:4000", + "sk-key", + ["claude"], + skip_verify=True, + base_env={}, + which=lambda name: "/usr/local/bin/claude", + launcher=lambda *a: order.append("launch"), + ) + assert order == ["launch"] + class TestAgentCommands: def setup_method(self): @@ -408,3 +435,41 @@ class TestAgentCommands: ) assert result.exit_code != 0 assert "could not reach proxy" in result.output + + def test_interactive_session_reattaches_terminal_before_handoff(self): + from litellm.proxy.client.cli.commands.agents import ( + _restore_controlling_terminal, + ) + + captured = {} + with ( + patch(f"{AGENTS_MODULE}._is_interactive", return_value=True), + patch( + f"{AGENTS_MODULE}.run_agent", + side_effect=lambda b, k, c, **kw: captured.update(kw), + ), + ): + result = self.runner.invoke( + _agent_command("claude"), + [], + obj={"base_url": "http://localhost:4000", "api_key": "sk-key"}, + ) + assert result.exit_code == 0, result.output + assert captured["reattach_terminal"] is _restore_controlling_terminal + + def test_non_interactive_agent_mode_leaves_stdin_alone(self): + captured = {} + with ( + patch(f"{AGENTS_MODULE}._is_interactive", return_value=False), + patch( + f"{AGENTS_MODULE}.run_agent", + side_effect=lambda b, k, c, **kw: captured.update(kw), + ), + ): + result = self.runner.invoke( + _agent_command("claude"), + [], + obj={"base_url": "http://localhost:4000", "api_key": "sk-key"}, + ) + assert result.exit_code == 0, result.output + assert captured["reattach_terminal"] is None