diff --git a/libs/cli/langgraph_cli/cli.py b/libs/cli/langgraph_cli/cli.py index 74df7aa3a..a90927f16 100644 --- a/libs/cli/langgraph_cli/cli.py +++ b/libs/cli/langgraph_cli/cli.py @@ -1008,6 +1008,80 @@ def deploy( ) +@click.option( + "--api-key", + envvar="LANGGRAPH_HOST_API_KEY", + help=( + "API key. Can also be set via LANGGRAPH_HOST_API_KEY, " + "LANGSMITH_API_KEY, or LANGCHAIN_API_KEY environment variable or .env file." + ), +) +@click.option( + "--host-url", + envvar="LANGGRAPH_HOST_URL", + default="https://api.host.langchain.com", + hidden=True, +) +@click.option( + "--force", + is_flag=True, + default=False, + help="Delete the deployment without prompting for confirmation.", +) +@click.argument("deployment_id") +@cli.command( + help=( + "[Beta] Delete a LangSmith Deployment.\n\n" + "This command is in beta and under active development." + ) +) +@log_command +def delete_deployment( + deployment_id: str, + host_url: str | None, + api_key: str | None, + force: bool, +) -> None: + click.secho( + "Note: 'langgraph delete-deployment' is in beta. Expect frequent updates and improvements.", + fg="yellow", + ) + click.echo() + + if not api_key: + api_key = click.prompt("Host API key", hide_input=True) + + if not force: + confirmation = click.prompt( + f"Are you sure you want to delete deployment ID {deployment_id} (Y/n)?", + default="N", + show_default=False, + ) + if confirmation.strip() != "Y": + raise click.ClickException("Deployment not deleted.") + + client = HostBackendClient(host_url, api_key) + try: + client.delete_deployment(deployment_id) + except HostBackendError as err: + if err.status_code == 403 and "requires workspace specification" in err.message: + click.secho( + "Your API key is org-scoped and requires a workspace ID.", + fg="yellow", + ) + click.secho( + "Find your workspace ID in LangSmith under Settings > Workspaces.", + fg="yellow", + ) + tenant_id = click.prompt("Workspace ID") + client = HostBackendClient(host_url, api_key, tenant_id=tenant_id) + client.delete_deployment(deployment_id) + else: + raise + + click.secho(f"Deleted deployment '{deployment_id}'.", fg="green") + + def _normalize_image_name(value: str | None) -> str: """Sanitize a deployment/directory name into a valid Docker repository name. diff --git a/libs/cli/langgraph_cli/host_backend.py b/libs/cli/langgraph_cli/host_backend.py index f53055051..4f72264dc 100644 --- a/libs/cli/langgraph_cli/host_backend.py +++ b/libs/cli/langgraph_cli/host_backend.py @@ -71,6 +71,9 @@ class HostBackendClient: def get_deployment(self, deployment_id: str) -> dict[str, Any]: return self._request("GET", f"/v2/deployments/{deployment_id}") + def delete_deployment(self, deployment_id: str) -> None: + self._request("DELETE", f"/v2/deployments/{deployment_id}") + def request_push_token(self, deployment_id: str) -> dict[str, Any]: return self._request( "POST", diff --git a/libs/cli/tests/unit_tests/cli/test_cli.py b/libs/cli/tests/unit_tests/cli/test_cli.py index 58ef68343..3b1af0dcb 100644 --- a/libs/cli/tests/unit_tests/cli/test_cli.py +++ b/libs/cli/tests/unit_tests/cli/test_cli.py @@ -7,6 +7,7 @@ import textwrap from contextlib import contextmanager from pathlib import Path +import pytest from click.testing import CliRunner from langgraph_cli.cli import cli, prepare_args_and_stdin @@ -287,6 +288,97 @@ def test_version_option() -> None: ) +def test_delete_deployment_command_calls_backend( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[tuple[str | None, str, str | None]] = [] + + class FakeHostBackendClient: + def __init__( + self, base_url: str | None, api_key: str, tenant_id: str | None = None + ) -> None: + calls.append((base_url, api_key, tenant_id)) + + def delete_deployment(self, deployment_id: str) -> None: + calls.append(("delete", deployment_id, None)) + + monkeypatch.setattr("langgraph_cli.cli.HostBackendClient", FakeHostBackendClient) + + runner = CliRunner() + result = runner.invoke( + cli, + ["delete-deployment", "--api-key", "test-key", "dep-123"], + input="Y\n", + ) + + assert result.exit_code == 0, result.output + assert calls == [ + ("https://api.host.langchain.com", "test-key", None), + ("delete", "dep-123", None), + ] + assert "Deleted deployment 'dep-123'." in result.output + + +def test_delete_deployment_command_requires_y( + monkeypatch: pytest.MonkeyPatch, +) -> None: + called = False + + class FakeHostBackendClient: + def __init__( + self, base_url: str | None, api_key: str, tenant_id: str | None = None + ) -> None: + nonlocal called + called = True + + def delete_deployment(self, deployment_id: str) -> None: + nonlocal called + called = True + + monkeypatch.setattr("langgraph_cli.cli.HostBackendClient", FakeHostBackendClient) + + runner = CliRunner() + result = runner.invoke( + cli, + ["delete-deployment", "--api-key", "test-key", "dep-123"], + input="N\n", + ) + + assert result.exit_code != 0 + assert "Deployment not deleted." in result.output + assert called is False + + +def test_delete_deployment_command_force_skips_confirmation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[tuple[str | None, str, str | None]] = [] + + class FakeHostBackendClient: + def __init__( + self, base_url: str | None, api_key: str, tenant_id: str | None = None + ) -> None: + calls.append((base_url, api_key, tenant_id)) + + def delete_deployment(self, deployment_id: str) -> None: + calls.append(("delete", deployment_id, None)) + + monkeypatch.setattr("langgraph_cli.cli.HostBackendClient", FakeHostBackendClient) + + runner = CliRunner() + result = runner.invoke( + cli, + ["delete-deployment", "--api-key", "test-key", "--force", "dep-123"], + ) + + assert result.exit_code == 0, result.output + assert "Type Y to delete deployment" not in result.output + assert calls == [ + ("https://api.host.langchain.com", "test-key", None), + ("delete", "dep-123", None), + ] + + def test_dockerfile_command_basic() -> None: """Test the 'dockerfile' command with basic configuration.""" runner = CliRunner() diff --git a/libs/cli/tests/unit_tests/test_host_backend.py b/libs/cli/tests/unit_tests/test_host_backend.py index 3e91d4553..cfbd21e0b 100644 --- a/libs/cli/tests/unit_tests/test_host_backend.py +++ b/libs/cli/tests/unit_tests/test_host_backend.py @@ -130,6 +130,10 @@ def test_get_deployment(client): assert result == {"ok": True} +def test_delete_deployment(client): + assert client.delete_deployment("dep-123") is None + + def test_list_deployments(client): result = client.list_deployments("my-app") assert result == {"ok": True}