diff --git a/libs/cli/langgraph_cli/cli.py b/libs/cli/langgraph_cli/cli.py index 74df7aa3a..bd80b2eea 100644 --- a/libs/cli/langgraph_cli/cli.py +++ b/libs/cli/langgraph_cli/cli.py @@ -124,6 +124,60 @@ def _secrets_from_env( return secrets +def _resolve_host_api_key( + api_key: str | None, env_vars: dict[str, str] | None = None +) -> str | None: + """Resolve the host API key from explicit input or supported env vars.""" + if api_key: + return api_key + env_vars = env_vars or {} + for key_name in _API_KEY_ENV_NAMES: + val = env_vars.get(key_name) or os.environ.get(key_name) + if val: + return val + return None + + +def _extract_deployment_url(deployment: dict[str, object]) -> str: + """Return the deployment URL exposed by the API response.""" + source_config = deployment.get("source_config") + if isinstance(source_config, dict): + for key in ("custom_url", "url"): + value = source_config.get(key) + if isinstance(value, str) and value: + return value + + return "-" + + +def _print_deployments(deployments: Sequence[dict[str, object]]) -> None: + """Render deployments in a simple aligned table.""" + if not deployments: + click.secho("No deployments found.", fg="yellow") + return + + rows = [ + ( + str(deployment.get("id", "-") or "-"), + str(deployment.get("name", "-") or "-"), + _extract_deployment_url(deployment), + ) + for deployment in deployments + ] + headers = ("Deployment ID", "Deployment Name", "Deployment URL") + widths = [ + max(len(headers[idx]), max(len(row[idx]) for row in rows)) + for idx in range(len(headers)) + ] + + click.secho( + " ".join(headers[idx].ljust(widths[idx]) for idx in range(len(headers))), + bold=True, + ) + for row in rows: + click.echo(" ".join(row[idx].ljust(widths[idx]) for idx in range(len(row)))) + + _TERMINAL_STATUSES = frozenset( [ "DEPLOYED", @@ -681,12 +735,7 @@ def deploy( env_vars = _parse_env_from_config(config_json, config) - if not api_key: - for key_name in _API_KEY_ENV_NAMES: - val = env_vars.get(key_name) or os.environ.get(key_name) - if val: - api_key = val - break + api_key = _resolve_host_api_key(api_key, env_vars) if not api_key: api_key = click.prompt("Host API key", hide_input=True) @@ -1008,6 +1057,66 @@ 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." + ), +) +@click.option( + "--host-url", + envvar="LANGGRAPH_HOST_URL", + default="https://api.host.langchain.com", + hidden=True, +) +@cli.command( + "list-deployments", + help=( + "[Beta] List LangSmith Deployments.\n\n" + "This command is in beta and under active development." + ), +) +@log_command +def list_deployments( + api_key: str | None, + host_url: str, +) -> None: + click.secho( + "Note: 'langgraph list-deployments' is in beta. Expect frequent updates and improvements.", + fg="yellow", + ) + click.echo() + + api_key = _resolve_host_api_key(api_key) + if not api_key: + api_key = click.prompt("Host API key", hide_input=True) + + client = HostBackendClient(host_url, api_key) + try: + response = client.list_deployments() + 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) + response = client.list_deployments() + else: + raise + + resources = response.get("resources", []) if isinstance(response, dict) else [] + deployments = [dep for dep in resources if isinstance(dep, dict)] + _print_deployments(deployments) + + 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..d52f8bc44 100644 --- a/libs/cli/langgraph_cli/host_backend.py +++ b/libs/cli/langgraph_cli/host_backend.py @@ -65,8 +65,12 @@ class HostBackendClient: def create_deployment(self, payload: dict[str, Any]) -> dict[str, Any]: return self._request("POST", "/v2/deployments", payload) - def list_deployments(self, name_contains: str) -> dict[str, Any]: - return self._request("GET", f"/v2/deployments?name_contains={name_contains}") + def list_deployments(self, name_contains: str | None = None) -> dict[str, Any]: + path = "/v2/deployments" + if name_contains: + query = httpx.QueryParams({"name_contains": name_contains}) + path = f"{path}?{query}" + return self._request("GET", path) def get_deployment(self, deployment_id: str) -> dict[str, Any]: return self._request("GET", f"/v2/deployments/{deployment_id}") diff --git a/libs/cli/tests/unit_tests/cli/test_cli.py b/libs/cli/tests/unit_tests/cli/test_cli.py index 58ef68343..482995030 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,65 @@ def test_version_option() -> None: ) +def test_list_deployments_command_formats_output(monkeypatch: pytest.MonkeyPatch): + class FakeHostBackendClient: + def __init__(self, base_url, api_key, tenant_id=None): + self.base_url = base_url + self.api_key = api_key + self.tenant_id = tenant_id + + def list_deployments(self): + return { + "resources": [ + { + "id": "dep_123", + "name": "alpha", + "source_config": {"custom_url": "https://alpha.example.com"}, + }, + { + "id": "dep_456", + "name": "beta", + "source_config": {"custom_url": "https://beta.example.com"}, + }, + ] + } + + monkeypatch.setattr("langgraph_cli.cli.HostBackendClient", FakeHostBackendClient) + + runner = CliRunner() + result = runner.invoke(cli, ["list-deployments", "--api-key", "test-key"]) + + assert result.exit_code == 0, result.output + assert "Deployment ID" in result.output + assert "Deployment Name" in result.output + assert "Deployment URL" in result.output + assert "dep_123" in result.output + assert "alpha" in result.output + assert "https://alpha.example.com" in result.output + assert "dep_456" in result.output + assert "beta" in result.output + assert "https://beta.example.com" in result.output + + +def test_list_deployments_command_empty_result(monkeypatch: pytest.MonkeyPatch): + class FakeHostBackendClient: + def __init__(self, base_url, api_key, tenant_id=None): + self.base_url = base_url + self.api_key = api_key + self.tenant_id = tenant_id + + def list_deployments(self): + return {"resources": []} + + monkeypatch.setattr("langgraph_cli.cli.HostBackendClient", FakeHostBackendClient) + + runner = CliRunner() + result = runner.invoke(cli, ["list-deployments", "--api-key", "test-key"]) + + assert result.exit_code == 0, result.output + assert "No deployments found." in result.output + + 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..ee2a8ffdf 100644 --- a/libs/cli/tests/unit_tests/test_host_backend.py +++ b/libs/cli/tests/unit_tests/test_host_backend.py @@ -135,6 +135,22 @@ def test_list_deployments(client): assert result == {"ok": True} +def test_list_deployments_without_filter_uses_base_path(): + def handler(req: httpx.Request) -> httpx.Response: + assert str(req.url) == "https://api.example.com/v2/deployments" + return httpx.Response(200, json={"ok": True}) + + c = HostBackendClient("https://api.example.com", "test-key") + c._client = httpx.Client( + base_url="https://api.example.com", + transport=httpx.MockTransport(handler), + headers={"X-Api-Key": "test-key", "Accept": "application/json"}, + timeout=30, + ) + result = c.list_deployments() + assert result == {"ok": True} + + def test_request_push_token(client): result = client.request_push_token("dep-123") assert result == {"ok": True}