From aae9b3a49c13de7300e0a5c0ca868b1dd85bb373 Mon Sep 17 00:00:00 2001 From: Nate Barbettini Date: Thu, 11 Dec 2025 12:58:55 -0800 Subject: [PATCH] feat: Support multiple orgs & projects in Arcade CLI (#717) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes [PLT-720: Refactor CLI to support multiple orgs + projects](https://linear.app/arcadedev/issue/PLT-720/refactor-cli-to-support-multiple-orgs-projects) This PR removes the legacy login flow (login to get an API key) from Arcade CLI. Believe it or not, this flow predates the ability to get an API key from the Dashboard, or even the Dashboard itself! Notable changes: **Legacy handling** - When a user with an existing `credentials.yaml` updates the CLI, they will get instructions on fixing their old credentials: Screenshot 2025-12-08 at 10 10 37 Any commands that require login (non-public commands) will be blocked with the above message until `arcade logout / arcade login` is performed again. **New login flow** ```sh arcade login Opening a browser to log you in... ✅ Logged in as nate@arcade.dev. Active project: Nate Barbettini's organization / Default project Run 'arcade org list' or 'arcade project list' to see available options. ``` **List and set the active organization** ```sh arcade org list ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━┓ ┃ Name ┃ ID ┃ Default ┃ Active ┃ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━┩ │ Nate Barbettini's organization │ 1c64968e-fdc5-4c55-8612-2ce46cd7881b │ ✓ │ ✓ │ │ Sergio 743 │ 1f1f6184-58dc-4bac-bdde-b9184e43fdf3 │ │ │ └────────────────────────────────┴──────────────────────────────────────┴─────────┴────────┘ Use 'arcade org set ' to switch organizations. ``` ```sh arcade org set 1c64968e-fdc5-4c55-8612-2ce46cd7881b ✓ Switched to organization: Nate Barbettini's organization Active project: Default project ``` **List and set the active project** ```sh arcade project list Active organization: Nate Barbettini's organization Use 'arcade org list' and 'arcade org set ' to switch organizations. ┏━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━┓ ┃ Name ┃ ID ┃ Default ┃ Active ┃ ┡━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━┩ │ Default project │ 35166bf3-6e68-481e-bf16-f747fadc6c22 │ ✓ │ ✓ │ │ Second project │ 62963205-31ea-4fda-9fc4-af10db89c06f │ │ │ └─────────────────┴──────────────────────────────────────┴─────────┴────────┘ Use 'arcade project set ' to switch projects. ``` ```sh arcade project set 35166bf3-6e68-481e-bf16-f747fadc6c22 ✓ Switched to project: Default project ``` --- > [!NOTE] > Migrates CLI to OAuth2 (PKCE) with saved org/project context, adds org/project commands, rewrites Engine calls to org-scoped endpoints, and bumps core packages. > > - **Auth & Config** > - Implement OAuth2 Authorization Code + PKCE (`arcade_cli/authn.py`) with local callback server and Jinja templates. > - Persist tokens and active `context` (org/project) in `credentials.yaml` via updated config models (`arcade_core/config_model.py`). > - Add token refresh and CLI config fetch utilities (`arcade_core/auth_tokens.py`). > - Detect legacy API-key credentials and block protected commands until re-login; add `whoami` command. > - **Org/Project Management** > - New subcommands: `arcade org list|set`, `arcade project list|set` (fetch via Coordinator). > - **Engine API usage (org-scoped)** > - Introduce org/project URL rewriting transports (`arcade_core/network/org_transport.py`) and helpers (`get_org_scoped_url`, `get_arcade_client`, `get_auth_headers`). > - Update `deploy`, `server`, and `secret` commands to use Bearer tokens and org-scoped paths; adjust log streaming/status, secrets CRUD, and deployment workflows. > - **CLI UX** > - Replace legacy login URLs/constants; add success/failure HTML templates for browser callback. > - Tweak `dashboard` to health-check without credentials. > - Usage tracking now includes `org_id`/`project_id` properties. > - **Tests** > - Update tests for dashboard, secrets, utils, and usage identity (OAuth `/whoami`). > - **Dependencies & Versions** > - Bump packages: `arcade-core@4.0.0`, `arcade-mcp-server@1.12.0`, `arcade-serve@3.2.0`, `arcade-tdk@3.3.0`; add `authlib`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 49702c2f74b9db15bb286d3ec71179b4e74a9134. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- libs/arcade-cli/arcade_cli/authn.py | 701 +++++++++++++++--- libs/arcade-cli/arcade_cli/constants.py | 153 +--- libs/arcade-cli/arcade_cli/deploy.py | 88 +-- libs/arcade-cli/arcade_cli/main.py | 145 +++- libs/arcade-cli/arcade_cli/org.py | 163 ++++ libs/arcade-cli/arcade_cli/project.py | 156 ++++ libs/arcade-cli/arcade_cli/secret.py | 57 +- libs/arcade-cli/arcade_cli/server.py | 39 +- .../arcade_cli/templates/base.jinja | 152 ++++ .../templates/cli_login_failed.jinja | 152 ++++ .../templates/cli_login_success.jinja | 28 + .../arcade_cli/usage/command_tracker.py | 27 + libs/arcade-cli/arcade_cli/usage/constants.py | 2 + libs/arcade-cli/arcade_cli/utils.py | 188 +++-- libs/arcade-core/arcade_core/auth_tokens.py | 110 +++ libs/arcade-core/arcade_core/config_model.py | 127 +++- libs/arcade-core/arcade_core/constants.py | 5 + .../arcade_core/network/__init__.py | 1 + .../arcade_core/network/org_transport.py | 99 +++ .../arcade-core/arcade_core/usage/identity.py | 64 +- libs/arcade-core/pyproject.toml | 3 +- .../arcade_mcp_server/server.py | 61 +- libs/arcade-mcp-server/pyproject.toml | 6 +- libs/arcade-serve/pyproject.toml | 4 +- libs/arcade-tdk/pyproject.toml | 4 +- libs/tests/cli/test_dashboard.py | 8 +- libs/tests/cli/test_secret.py | 70 +- libs/tests/cli/test_utils.py | 46 +- libs/tests/cli/usage/test_identity.py | 24 + pyproject.toml | 18 +- 30 files changed, 2136 insertions(+), 565 deletions(-) create mode 100644 libs/arcade-cli/arcade_cli/org.py create mode 100644 libs/arcade-cli/arcade_cli/project.py create mode 100644 libs/arcade-cli/arcade_cli/templates/base.jinja create mode 100644 libs/arcade-cli/arcade_cli/templates/cli_login_failed.jinja create mode 100644 libs/arcade-cli/arcade_cli/templates/cli_login_success.jinja create mode 100644 libs/arcade-core/arcade_core/auth_tokens.py create mode 100644 libs/arcade-core/arcade_core/network/__init__.py create mode 100644 libs/arcade-core/arcade_core/network/org_transport.py diff --git a/libs/arcade-cli/arcade_cli/authn.py b/libs/arcade-cli/arcade_cli/authn.py index 621d8c3f..3073770f 100644 --- a/libs/arcade-cli/arcade_cli/authn.py +++ b/libs/arcade-cli/arcade_cli/authn.py @@ -1,150 +1,663 @@ +""" +OAuth authentication module for Arcade CLI. + +Implements OAuth 2.0 Authorization Code flow with PKCE for secure CLI authentication. +Uses authlib for OAuth protocol handling. +""" + import os +import secrets import threading +import uuid +import webbrowser +from collections.abc import Generator +from contextlib import contextmanager +from dataclasses import dataclass +from datetime import datetime, timedelta from http.server import BaseHTTPRequestHandler, HTTPServer -from typing import Any +from pathlib import Path +from typing import Any, Callable from urllib.parse import parse_qs +import httpx import yaml -from arcade_core.constants import ( - ARCADE_CONFIG_PATH, - CREDENTIALS_FILE_PATH, +from arcade_core.auth_tokens import ( + CLIConfig, + TokenResponse, + fetch_cli_config, + get_valid_access_token, ) +from arcade_core.config_model import AuthConfig, Config, ContextConfig, UserConfig +from arcade_core.constants import ARCADE_CONFIG_PATH, CREDENTIALS_FILE_PATH +from authlib.integrations.httpx_client import OAuth2Client +from jinja2 import Environment, FileSystemLoader +from pydantic import AliasChoices, BaseModel, Field from rich.console import Console -from arcade_cli.constants import ( - LOGIN_FAILED_HTML, - LOGIN_SUCCESS_HTML, -) +# Set up Jinja2 templates +_TEMPLATES_DIR = Path(__file__).parent / "templates" +_jinja_env = Environment(loader=FileSystemLoader(_TEMPLATES_DIR), autoescape=True) + + +def _render_template(template_name: str, **context: Any) -> bytes: + """Render a Jinja2 template and return as bytes.""" + template = _jinja_env.get_template(template_name) + return template.render(**context).encode("utf-8") + console = Console() +# OAuth constants +DEFAULT_SCOPES = "openid offline_access" +LOCAL_CALLBACK_PORT = 9905 -class LoginCallbackHandler(BaseHTTPRequestHandler): - def __init__(self, *args, state: str, **kwargs): # type: ignore[no-untyped-def] - self.state = state # Simple CSRF protection + +def create_oauth_client(cli_config: CLIConfig) -> OAuth2Client: # type: ignore[no-any-unimported] + """ + Create an authlib OAuth2Client configured for the CLI. + + Args: + cli_config: OAuth configuration from Coordinator + + Returns: + Configured OAuth2Client with PKCE support + """ + return OAuth2Client( + client_id=cli_config.client_id, + token_endpoint=cli_config.token_endpoint, + code_challenge_method="S256", + ) + + +def generate_authorization_url( # type: ignore[no-any-unimported] + client: OAuth2Client, + cli_config: CLIConfig, + redirect_uri: str, + state: str, +) -> tuple[str, str]: + """ + Generate OAuth authorization URL with PKCE. + + Args: + client: OAuth2Client instance + cli_config: OAuth configuration from Coordinator + redirect_uri: Callback URL for the authorization response + state: Random state for CSRF protection + + Returns: + Tuple of (authorization_url, code_verifier) + """ + # Generate PKCE code verifier + code_verifier = secrets.token_urlsafe(64) + + url, _ = client.create_authorization_url( + cli_config.authorization_endpoint, + redirect_uri=redirect_uri, + scope=DEFAULT_SCOPES, + state=state, + code_verifier=code_verifier, + ) + return url, code_verifier + + +def exchange_code_for_tokens( # type: ignore[no-any-unimported] + client: OAuth2Client, + code: str, + redirect_uri: str, + code_verifier: str, +) -> TokenResponse: + """ + Exchange authorization code for tokens using authlib. + + Args: + client: OAuth2Client instance + code: Authorization code from callback + redirect_uri: Same redirect URI used in authorization request + code_verifier: PKCE code verifier from authorization request + + Returns: + TokenResponse with access and refresh tokens + """ + token = client.fetch_token( + client.session.metadata["token_endpoint"], + grant_type="authorization_code", + code=code, + redirect_uri=redirect_uri, + code_verifier=code_verifier, + ) + + return TokenResponse( + access_token=token["access_token"], + refresh_token=token["refresh_token"], + expires_in=token["expires_in"], + token_type=token["token_type"], + ) + + +class OrgInfo(BaseModel): + """Organization info from Coordinator.""" + + org_id: str = Field(validation_alias=AliasChoices("org_id", "organization_id")) + name: str + is_default: bool = False + + +class ProjectInfo(BaseModel): + """Project info from Coordinator.""" + + project_id: str + name: str + is_default: bool = False + + +def select_default_org(orgs: list[OrgInfo]) -> OrgInfo | None: + """ + Select the default organization. + + Args: + orgs: List of organizations + + Returns: + Default org, or first org, or None if empty + """ + if not orgs: + return None + for org in orgs: + if org.is_default: + return org + return orgs[0] + + +def select_default_project(projects: list[ProjectInfo]) -> ProjectInfo | None: + """ + Select the default project. + + Args: + projects: List of projects + + Returns: + Default project, or first project, or None if empty + """ + if not projects: + return None + for project in projects: + if project.is_default: + return project + return projects[0] + + +class WhoAmIResponse(BaseModel): + """Response from Coordinator /whoami endpoint.""" + + account_id: str + email: str + organizations: list[OrgInfo] = [] + projects: list[ProjectInfo] = [] + + def get_selected_org(self) -> OrgInfo | None: + """Get the org to use: default if available, otherwise first in list.""" + return select_default_org(self.organizations) + + def get_selected_project(self) -> ProjectInfo | None: + """Get the project to use: default if available, otherwise first in list.""" + return select_default_project(self.projects) + + +def fetch_whoami(coordinator_url: str, access_token: str) -> WhoAmIResponse: + """ + Fetch user info and all orgs/projects from the Coordinator. + + This is the preferred way to get user info after OAuth login, as it: + - Only accepts short-lived access tokens (not API keys) + - Returns user email and account ID + - Returns all orgs and projects the user has access to + + Args: + coordinator_url: Base URL of the Coordinator + access_token: Valid OAuth access token + + Returns: + WhoAmIResponse with account info and all orgs/projects + """ + url = f"{coordinator_url}/api/v1/auth/whoami" + response = httpx.get( + url, + headers={"Authorization": f"Bearer {access_token}"}, + timeout=30, + ) + response.raise_for_status() + data = response.json().get("data", {}) + + return WhoAmIResponse.model_validate(data) + + +def fetch_organizations(coordinator_url: str) -> list[OrgInfo]: + """ + Fetch organizations the user belongs to. + + Args: + coordinator_url: Base URL of the Coordinator + access_token: Valid access token + + Returns: + List of organizations + """ + url = f"{coordinator_url}/api/v1/orgs" + access_token = get_valid_access_token(coordinator_url) + response = httpx.get( + url, + headers={"Authorization": f"Bearer {access_token}"}, + timeout=30, + ) + response.raise_for_status() + data = response.json() + + return [OrgInfo.model_validate(item) for item in data.get("data", {}).get("items", [])] + + +def fetch_projects(coordinator_url: str, org_id: str) -> list[ProjectInfo]: + """ + Fetch projects in an organization. + + Args: + coordinator_url: Base URL of the Coordinator + access_token: Valid access token + org_id: Organization ID + + Returns: + List of projects + """ + url = f"{coordinator_url}/api/v1/orgs/{org_id}/projects" + access_token = get_valid_access_token(coordinator_url) + response = httpx.get( + url, + headers={"Authorization": f"Bearer {access_token}"}, + timeout=30, + ) + response.raise_for_status() + data = response.json() + + return [ProjectInfo.model_validate(item) for item in data.get("data", {}).get("items", [])] + + +class OAuthCallbackHandler(BaseHTTPRequestHandler): + """HTTP request handler for OAuth callback.""" + + def __init__(self, *args, state: str, result_holder: dict, **kwargs): # type: ignore[no-untyped-def] + self.state = state + self.result_holder = result_holder + # Store error details for template rendering + self._error: str | None = None + self._error_description: str | None = None + self._returned_state: str | None = None super().__init__(*args, **kwargs) - def log_message(self, format: str, *args: Any) -> None: # noqa: A002 Argument `format` is shadowing a Python builtin - # Override to suppress logging to stdout + def log_message(self, format: str, *args: Any) -> None: # noqa: A002 + # Suppress logging to stdout pass - def _parse_login_response(self) -> tuple[str, str, str] | None: - # Parse the query string from the URL - query_string = self.path.split("?", 1)[-1] + def do_GET(self) -> None: + """Handle GET request (OAuth callback).""" + query_string = self.path.split("?", 1)[-1] if "?" in self.path else "" params = parse_qs(query_string) - returned_state = params.get("state", [None])[0] - if returned_state != self.state: - console.print( - "❌ Login failed: Invalid login attempt. Please try again.", style="bold red" + self._returned_state = params.get("state", [None])[0] + code = params.get("code", [None])[0] + self._error = params.get("error", [None])[0] + self._error_description = params.get("error_description", [None])[0] + + if self._returned_state != self.state: + self.result_holder["error"] = "Invalid state parameter. Possible CSRF attack." + self._send_error_response( + message="Invalid state parameter. This may be a security issue." ) - return None + return - api_key = params.get("api_key", [None])[0] or "" - email = params.get("email", [None])[0] or "" - warning = params.get("warning", [None])[0] or "" + if self._error: + self.result_holder["error"] = self._error_description or self._error + self._send_error_response() + return - return api_key, email, warning + if not code: + self.result_holder["error"] = "No authorization code received." + self._send_error_response(message="No authorization code was received from the server.") + return - def _handle_login_response(self) -> bool: - result = self._parse_login_response() - if result is None: - return False - api_key, email, warning = result + self.result_holder["code"] = code + self._send_success_response() - if warning: - console.print(warning, style="bold yellow") + def _send_success_response(self) -> None: + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + self.wfile.write(_render_template("cli_login_success.jinja")) + threading.Thread(target=self.server.shutdown).start() - # If API key and email are received, store them in a file - if not api_key or not email: - console.print( - "❌ Login failed: No credentials received. Please try again.", style="bold red" + def _send_error_response(self, message: str | None = None) -> None: + self.send_response(400) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + self.wfile.write( + _render_template( + "cli_login_failed.jinja", + message=message, + error=self._error, + error_description=self._error_description, + state=self._returned_state, ) - return False - - # ensure the ARCADE_CONFIG_PATH directory exists - if not os.path.exists(ARCADE_CONFIG_PATH): - os.makedirs(ARCADE_CONFIG_PATH, exist_ok=True) - - # TODO don't overwrite existing config - new_config = {"cloud": {"api": {"key": api_key}, "user": {"email": email}}} - with open(CREDENTIALS_FILE_PATH, "w") as f: - yaml.dump(new_config, f) - - # Send a success response to the browser - console.print( - f"""✅ Hi there, {email}! - -Your Arcade API key is: {api_key} -Stored in: {CREDENTIALS_FILE_PATH}""", - style="bold green", ) - return True - - def do_GET(self) -> None: # This naming is correct, required by BaseHTTPRequestHandler - success = self._handle_login_response() - if success: - self.send_response(200) - self.end_headers() - self.wfile.write(LOGIN_SUCCESS_HTML) - else: - self.send_response(400) - self.end_headers() - self.wfile.write(LOGIN_FAILED_HTML) - - # Always shut down the server so it doesn't keep running threading.Thread(target=self.server.shutdown).start() -class LocalAuthCallbackServer: - def __init__(self, state: str, port: int = 9905): +class OAuthCallbackServer: + """Local HTTP server for OAuth callback.""" + + def __init__(self, state: str, port: int = LOCAL_CALLBACK_PORT): self.state = state self.port = port self.httpd: HTTPServer | None = None + self.result: dict[str, Any] = {} def run_server(self) -> None: - # Initialize and run the server + """Start the callback server.""" server_address = ("", self.port) - handler = lambda *args, **kwargs: LoginCallbackHandler(*args, state=self.state, **kwargs) + handler = lambda *args, **kwargs: OAuthCallbackHandler( + *args, state=self.state, result_holder=self.result, **kwargs + ) self.httpd = HTTPServer(server_address, handler) self.httpd.serve_forever() def shutdown_server(self) -> None: - # Shut down the server gracefully + """Shut down the callback server.""" if self.httpd: self.httpd.shutdown() + def get_redirect_uri(self) -> str: + """Get the redirect URI for this server.""" + return f"http://localhost:{self.port}/callback" + + +def save_credentials_from_whoami( + tokens: TokenResponse, + whoami: WhoAmIResponse, + coordinator_url: str, +) -> None: + """ + Save OAuth credentials to the config file using WhoAmI response. + + Picks the org/project marked as default, or falls back to the first one + in the list if none are marked as default. + + Args: + tokens: OAuth tokens + whoami: Response from /whoami endpoint with user and orgs/projects + """ + # Ensure config directory exists + os.makedirs(ARCADE_CONFIG_PATH, exist_ok=True) + + expires_at = datetime.now() + timedelta(seconds=tokens.expires_in) + + context = None + selected_org = whoami.get_selected_org() + selected_project = whoami.get_selected_project() + + if selected_org and selected_project: + context = ContextConfig( + org_id=selected_org.org_id, + org_name=selected_org.name, + project_id=selected_project.project_id, + project_name=selected_project.name, + ) + + config = Config( + coordinator_url=coordinator_url, + auth=AuthConfig( + access_token=tokens.access_token, + refresh_token=tokens.refresh_token, + expires_at=expires_at, + ), + user=UserConfig(email=whoami.email), + context=context, + ) + + config.save_to_file() + + +def get_active_context() -> tuple[str, str]: + """ + Get the active org and project IDs. + + Returns: + Tuple of (org_id, project_id) + + Raises: + ValueError: If not logged in or no context set + """ + try: + config = Config.load_from_file() + except FileNotFoundError: + raise ValueError("Not logged in. Please run 'arcade login' first.") + + if not config.context: + raise ValueError("No active organization/project. Please run 'arcade login' first.") + + return config.context.org_id, config.context.project_id + + +# ============================================================================= +# High-level OAuth login flow +# ============================================================================= + + +class OAuthLoginError(Exception): + """Error during OAuth login flow.""" + + pass + + +@dataclass +class OAuthLoginResult: + """Result of a successful OAuth login flow.""" + + tokens: TokenResponse + whoami: WhoAmIResponse + + @property + def email(self) -> str: + return self.whoami.email + + @property + def selected_org(self) -> OrgInfo | None: + return self.whoami.get_selected_org() + + @property + def selected_project(self) -> ProjectInfo | None: + return self.whoami.get_selected_project() + + +def build_coordinator_url(host: str, port: int | None) -> str: + """ + Build the Coordinator URL from host and optional port. + + Args: + host: The Arcade Coordinator host + port: Optional port (used for local development) + + Returns: + Full coordinator URL (e.g., https://api.arcade.dev) + """ + if port: + scheme = "http" if host == "localhost" else "https" + return f"{scheme}://{host}:{port}" + else: + scheme = "http" if host == "localhost" else "https" + default_port = ":8000" if host == "localhost" else "" + return f"{scheme}://{host}{default_port}" + + +@contextmanager +def oauth_callback_server(state: str) -> Generator[OAuthCallbackServer, None, None]: + """ + Context manager for the OAuth callback server. + + Ensures the server is properly shut down even if an error occurs. + Waits for the callback to be received before exiting. + + Usage: + with oauth_callback_server(state) as server: + # server is running and waiting for callback + ... + # After the with block, the server has received the callback + """ + server = OAuthCallbackServer(state) + server_thread = threading.Thread(target=server.run_server) + server_thread.start() + try: + yield server + # Wait for the callback to be received (server shuts itself down after handling) + server_thread.join() + finally: + # Clean up if interrupted or if something went wrong + if server_thread.is_alive(): + server.shutdown_server() + server_thread.join(timeout=2) + + +def perform_oauth_login( + coordinator_url: str, + on_status: Callable[[str], None] | None = None, +) -> OAuthLoginResult: + """ + Perform the complete OAuth login flow. + + This function: + 1. Fetches OAuth config from the Coordinator + 2. Starts a local callback server + 3. Opens browser for user authentication + 4. Exchanges authorization code for tokens + 5. Fetches user info and validates org/project + + Args: + coordinator_url: Base URL of the Coordinator + on_status: Optional callback for status messages (e.g., console.print) + + Returns: + OAuthLoginResult with tokens and user info + + Raises: + OAuthLoginError: If any step of the login flow fails + """ + + def status(msg: str) -> None: + if on_status: + on_status(msg) + + # Step 1: Fetch OAuth config + try: + cli_config = fetch_cli_config(coordinator_url) + except Exception as e: + raise OAuthLoginError(f"Could not connect to Arcade at {coordinator_url}") from e + + # Step 2: Create OAuth client and prepare PKCE + oauth_client = create_oauth_client(cli_config) + state = str(uuid.uuid4()) + + # Step 3: Start local callback server and run browser auth + with oauth_callback_server(state) as server: + redirect_uri = server.get_redirect_uri() + + # Step 4: Generate authorization URL and open browser + auth_url, code_verifier = generate_authorization_url( + oauth_client, cli_config, redirect_uri, state + ) + + status("Opening a browser to log you in...") + if not webbrowser.open(auth_url): + status(f"Copy this URL into your browser:\n{auth_url}") + + # Step 5: Wait for callback (server thread handles this via serve_forever) + # The thread will exit when the callback handler calls server.shutdown() + + # Check for errors from callback + if "error" in server.result: + raise OAuthLoginError(f"Login failed: {server.result['error']}") + + if "code" not in server.result: + raise OAuthLoginError("No authorization code received") + + # Step 6: Exchange code for tokens + code = server.result["code"] + tokens = exchange_code_for_tokens(oauth_client, code, redirect_uri, code_verifier) + + # Step 7: Fetch user info + whoami = fetch_whoami(coordinator_url, tokens.access_token) + + # Validate org/project exist + if not whoami.get_selected_org(): + raise OAuthLoginError( + "No organizations found for your account. " + "Please contact support@arcade.dev for assistance." + ) + + if not whoami.get_selected_project(): + org_name = whoami.get_selected_org().name # type: ignore[union-attr] + raise OAuthLoginError( + f"No projects found in organization '{org_name}'. " + "Please contact support@arcade.dev for assistance." + ) + + return OAuthLoginResult(tokens=tokens, whoami=whoami) + + +def _credentials_file_contains_legacy() -> bool: + """ + Detect legacy (API key) credentials in the credentials file. + """ + try: + with open(CREDENTIALS_FILE_PATH) as f: + data = yaml.safe_load(f) or {} + cloud = data.get("cloud", {}) + return isinstance(cloud, dict) and "api" in cloud + except Exception: + return False + def check_existing_login(suppress_message: bool = False) -> bool: """ - Check if the user is already logged in by verifying the config file. + Check if the user is already logged in. Args: - suppress_message (bool): If True, suppress the logged in message. + suppress_message: If True, suppress the logged in message. Returns: - bool: True if the user is already logged in, False otherwise. + True if the user is already logged in, False otherwise. """ if not os.path.exists(CREDENTIALS_FILE_PATH): return False - if os.path.exists(CREDENTIALS_FILE_PATH): - try: - with open(CREDENTIALS_FILE_PATH) as f: - config: dict[str, Any] = yaml.safe_load(f) - cloud_config = config.get("cloud", {}) - api_key = cloud_config.get("api", {}).get("key") - email = cloud_config.get("user", {}).get("email") + try: + with open(CREDENTIALS_FILE_PATH) as f: + config_data: dict[str, Any] = yaml.safe_load(f) - if api_key and email: - if not suppress_message: - console.print(f"You're already logged in as {email}. ", style="bold green") - return True - except yaml.YAMLError: - console.print( - f"Error: Invalid configuration file at {CREDENTIALS_FILE_PATH}", style="bold red" - ) - except Exception as e: - console.print(f"Error: Unable to read configuration file: {e!s}", style="bold red") + cloud_config = config_data.get("cloud", {}) if isinstance(config_data, dict) else {} - return True + auth = cloud_config.get("auth", {}) + if auth.get("access_token"): + email = cloud_config.get("user", {}).get("email", "unknown") + context = cloud_config.get("context", {}) + org_name = context.get("org_name", "unknown") + project_name = context.get("project_name", "unknown") + + if not suppress_message: + console.print(f"You're already logged in as {email}.", style="bold green") + console.print(f"Active: {org_name} / {project_name}", style="dim") + return True + + except yaml.YAMLError: + console.print( + f"Error: Invalid configuration file at {CREDENTIALS_FILE_PATH}", style="bold red" + ) + except Exception as e: + console.print(f"Error: Unable to read configuration file: {e!s}", style="bold red") + + return False diff --git a/libs/arcade-cli/arcade_cli/constants.py b/libs/arcade-cli/arcade_cli/constants.py index e5dd097f..bc8be0a4 100644 --- a/libs/arcade-cli/arcade_cli/constants.py +++ b/libs/arcade-cli/arcade_cli/constants.py @@ -1,148 +1,7 @@ -PROD_CLOUD_HOST = "cloud.arcade.dev" -PROD_ENGINE_HOST = "api.arcade.dev" -LOCALHOST = "localhost" -LOCAL_AUTH_CALLBACK_PORT = 9905 +from arcade_core.constants import LOCALHOST, PROD_COORDINATOR_HOST, PROD_ENGINE_HOST -_style_block = b""" - - - - - - - -""" - -LOGIN_SUCCESS_HTML = ( - b""" - - - - - - Success! - """ - + _style_block - + b""" - - -
- -

Log in to Arcade CLI

-

Success! You can close this window.

-
- - -""" -) - -LOGIN_FAILED_HTML = ( - b""" - - - - - - Login failed - """ - + _style_block - + b""" - - -
- -

Log in to Arcade CLI

-

Something went wrong. Please close this window and try again.

-
- - -""" -) +__all__ = [ + "LOCALHOST", + "PROD_COORDINATOR_HOST", + "PROD_ENGINE_HOST", +] diff --git a/libs/arcade-cli/arcade_cli/deploy.py b/libs/arcade-cli/arcade_cli/deploy.py index 927701a3..82a64ff0 100644 --- a/libs/arcade-cli/arcade_cli/deploy.py +++ b/libs/arcade-cli/arcade_cli/deploy.py @@ -23,7 +23,12 @@ from rich.text import Text from typing_extensions import Literal from arcade_cli.secret import load_env_file -from arcade_cli.utils import compute_base_url, validate_and_get_config +from arcade_cli.utils import ( + compute_base_url, + get_auth_headers, + get_org_scoped_url, + validate_and_get_config, +) console = Console() @@ -89,7 +94,7 @@ class UpdateDeploymentRequest(BaseModel): # Deployment Status Functions -def get_deployment_status(engine_url: str, api_key: str, server_name: str) -> str: +def _get_deployment_status(engine_url: str, server_name: str) -> str: """ Get the status of a deployment. @@ -101,12 +106,9 @@ def get_deployment_status(engine_url: str, api_key: str, server_name: str) -> st The status of the deployment. Possible values are: "pending", "updating", "unknown", "running", "failed". """ - client = httpx.Client( - base_url=engine_url, - headers={"Authorization": f"Bearer {api_key}"}, - timeout=360, - ) - response = client.get(f"/v1/deployments/{server_name}/status") + url = get_org_scoped_url(engine_url, f"/deployments/{server_name}/status") + client = httpx.Client(headers=get_auth_headers(), timeout=360) + response = client.get(url) response.raise_for_status() status = cast(str, response.json().get("status", "unknown")) return status @@ -114,7 +116,6 @@ def get_deployment_status(engine_url: str, api_key: str, server_name: str) -> st async def _poll_deployment_status( engine_url: str, - api_key: str, server_name: str, state: dict, debug: bool = False, @@ -122,7 +123,7 @@ async def _poll_deployment_status( """Poll deployment status until it's running or error.""" while state["status"] in ["pending", "unknown", "updating"]: try: - status = get_deployment_status(engine_url, api_key, server_name) + status = _get_deployment_status(engine_url, server_name) state["status"] = status if status in ["running", "failed"]: break @@ -134,21 +135,20 @@ async def _poll_deployment_status( async def _stream_deployment_logs_to_deque( engine_url: str, - api_key: str, server_name: str, log_deque: deque, state: dict, debug: bool = False, ) -> None: """Stream deployment logs into a deque with retry logic.""" - stream_url = f"{engine_url}/v1/deployments/{server_name}/logs/stream" - headers = {"Authorization": f"Bearer {api_key}"} + stream_url = get_org_scoped_url(engine_url, f"/deployments/{server_name}/logs/stream") while state["status"] in ["pending", "unknown", "updating"]: try: + auth_headers = get_auth_headers() async with ( httpx.AsyncClient(timeout=None) as client, # noqa: S113 - expected indefinite log stream - client.stream("GET", stream_url, headers=headers) as response, + client.stream("GET", stream_url, headers=auth_headers) as response, ): response.raise_for_status() async for line in response.aiter_lines(): @@ -169,7 +169,6 @@ async def _stream_deployment_logs_to_deque( async def _monitor_deployment_with_logs( engine_url: str, - api_key: str, server_name: str, debug: bool = False, is_update: bool = False, @@ -179,7 +178,6 @@ async def _monitor_deployment_with_logs( Args: engine_url: The base URL of the Arcade Engine - api_key: The API key for authentication server_name: The name of the server to monitor debug: Whether to show debug information is_update: If True, wait for status to be 'updating' before streaming logs or 'failed' before exiting @@ -199,7 +197,7 @@ async def _monitor_deployment_with_logs( ] status_task = asyncio.create_task( - _poll_deployment_status(engine_url, api_key, server_name, state, debug) + _poll_deployment_status(engine_url, server_name, state, debug) ) # Don't stream logs until the deployment is 'updating' or 'failed' otherwise we will get logs from the previous deployment @@ -209,7 +207,7 @@ async def _monitor_deployment_with_logs( # Start log streaming task logs_task = asyncio.create_task( - _stream_deployment_logs_to_deque(engine_url, api_key, server_name, log_deque, state, debug) + _stream_deployment_logs_to_deque(engine_url, server_name, log_deque, state, debug) ) # Live display with spinner and logs @@ -281,10 +279,11 @@ async def _monitor_deployment_with_logs( # Create Deployment Functions -def server_already_exists(engine_url: str, api_key: str, server_name: str) -> bool: +def server_already_exists(engine_url: str, server_name: str) -> bool: """Check if a server already exists in the Arcade Engine.""" - client = httpx.Client(base_url=engine_url, headers={"Authorization": f"Bearer {api_key}"}) - response = client.get(f"/v1/workers/{server_name}") + url = get_org_scoped_url(engine_url, f"/workers/{server_name}") + client = httpx.Client(headers=get_auth_headers()) + response = client.get(url) if response.status_code == 404: return False @@ -294,11 +293,14 @@ def server_already_exists(engine_url: str, api_key: str, server_name: str) -> bo def update_deployment( - engine_url: str, api_key: str, server_name: str, update_deployment_request: dict + engine_url: str, + server_name: str, + update_deployment_request: dict, ) -> None: """Update a deployment in the Arcade Engine.""" - client = httpx.Client(base_url=engine_url, headers={"Authorization": f"Bearer {api_key}"}) - response = client.put(f"/v1/deployments/{server_name}", json=update_deployment_request) + url = get_org_scoped_url(engine_url, f"/deployments/{server_name}") + client = httpx.Client(headers=get_auth_headers()) + response = client.put(url, json=update_deployment_request) response.raise_for_status() @@ -590,21 +592,22 @@ def verify_server_and_get_metadata( def upsert_secrets_to_engine( - engine_url: str, api_key: str, secrets: set[str], debug: bool = False + engine_url: str, + secrets: set[str], + debug: bool = False, ) -> None: """ Upsert secrets to the Arcade Engine. Args: engine_url: The base URL of the Arcade Engine - api_key: The API key for authentication secrets: Set of secret keys to upsert debug: Whether to show debug information """ if not secrets: return - client = httpx.Client(base_url=engine_url, headers={"Authorization": f"Bearer {api_key}"}) + client = httpx.Client(headers=get_auth_headers()) for secret_key in sorted(secrets): secret_value = os.getenv(secret_key) @@ -623,8 +626,9 @@ def upsert_secrets_to_engine( try: # Upsert secret to engine + url = get_org_scoped_url(engine_url, f"/secrets/{secret_key}") response = client.put( - f"/v1/admin/secrets/{secret_key}", + url, json={"description": "Secret set via CLI", "value": secret_value}, timeout=30, ) @@ -644,14 +648,15 @@ def upsert_secrets_to_engine( def deploy_server_to_engine( - engine_url: str, api_key: str, deployment_request: dict, debug: bool = False + engine_url: str, + deployment_request: dict, + debug: bool = False, ) -> dict: """ Deploy the server to Arcade Engine. Args: engine_url: The base URL of the Arcade Engine - api_key: The API key for authentication deployment_request: The deployment request payload debug: Whether to show debug information @@ -662,14 +667,11 @@ def deploy_server_to_engine( httpx.HTTPStatusError: If the deployment request fails httpx.ConnectError: If connection to the engine fails """ - client = httpx.Client( - base_url=engine_url, - headers={"Authorization": f"Bearer {api_key}"}, - timeout=360, - ) + url = get_org_scoped_url(engine_url, "/deployments") + client = httpx.Client(headers=get_auth_headers(), timeout=360) try: - response = client.post("/v1/deployments", json=deployment_request) + response = client.post(url, json=deployment_request) response.raise_for_status() return cast(dict, response.json()) except httpx.ConnectError as e: @@ -793,7 +795,7 @@ def deploy_server_logic( secrets_to_upsert = set(load_env_file(str(env_path)).keys()) if secrets_to_upsert: console.print(f"✓ Found {len(secrets_to_upsert)} secret(s) in .env file", style="green") - upsert_secrets_to_engine(engine_url, config.api.key, secrets_to_upsert, debug) + upsert_secrets_to_engine(engine_url, secrets_to_upsert, debug) else: console.print("[!] No secrets found in .env file", style="yellow") elif secrets == "auto": @@ -803,9 +805,7 @@ def deploy_server_logic( f"\nUploading {len(required_secrets_from_validation)} required secret(s) to Arcade...", style="dim", ) - upsert_secrets_to_engine( - engine_url, config.api.key, required_secrets_from_validation, debug - ) + upsert_secrets_to_engine(engine_url, required_secrets_from_validation, debug) else: console.print("\n✓ No required secrets found", style="green") @@ -830,26 +830,26 @@ def deploy_server_logic( ) deployment_toolkits = DeploymentToolkits(bundles=[toolkit_bundle]) - if server_already_exists(engine_url, config.api.key, server_name): + if server_already_exists(engine_url, server_name): is_update = True update_request = UpdateDeploymentRequest( description="MCP Server deployed via CLI", toolkits=deployment_toolkits, ) - update_deployment(engine_url, config.api.key, server_name, update_request.model_dump()) + update_deployment(engine_url, server_name, update_request.model_dump()) else: create_request = CreateDeploymentRequest( name=server_name, description="MCP Server deployed via CLI", toolkits=deployment_toolkits, ) - deploy_server_to_engine(engine_url, config.api.key, create_request.model_dump(), debug) + deploy_server_to_engine(engine_url, create_request.model_dump(), debug) except Exception as e: raise ValueError(f"Deployment failed: {e}") from e # Step 8: Monitor deployment with live status and logs final_status, all_logs = asyncio.run( - _monitor_deployment_with_logs(engine_url, config.api.key, server_name, debug, is_update) + _monitor_deployment_with_logs(engine_url, server_name, debug, is_update) ) if final_status == "running": diff --git a/libs/arcade-cli/arcade_cli/main.py b/libs/arcade-cli/arcade_cli/main.py index d9515928..d9ad8913 100644 --- a/libs/arcade-cli/arcade_cli/main.py +++ b/libs/arcade-cli/arcade_cli/main.py @@ -2,28 +2,31 @@ import asyncio import os import subprocess import sys -import threading -import uuid import webbrowser from pathlib import Path from typing import Optional import click import typer -from arcade_core.constants import CREDENTIALS_FILE_PATH +from arcade_core.constants import CREDENTIALS_FILE_PATH, PROD_COORDINATOR_HOST, PROD_ENGINE_HOST from arcadepy import Arcade from rich.console import Console from rich.text import Text from tqdm import tqdm -from arcade_cli.authn import LocalAuthCallbackServer, check_existing_login -from arcade_cli.constants import ( - PROD_CLOUD_HOST, - PROD_ENGINE_HOST, +from arcade_cli.authn import ( + OAuthLoginError, + _credentials_file_contains_legacy, + build_coordinator_url, + check_existing_login, + perform_oauth_login, + save_credentials_from_whoami, ) from arcade_cli.display import ( display_eval_results, ) +from arcade_cli.org import app as org_app +from arcade_cli.project import app as project_app from arcade_cli.secret import app as secret_app from arcade_cli.server import app as server_app from arcade_cli.show import show_logic @@ -31,14 +34,12 @@ from arcade_cli.usage.command_tracker import TrackedTyper, TrackedTyperGroup from arcade_cli.utils import ( Provider, compute_base_url, - compute_login_url, get_eval_files, handle_cli_error, load_eval_suites, log_engine_health, require_dependency, resolve_provider_api_key, - validate_and_get_config, version_callback, ) @@ -74,71 +75,70 @@ cli.add_typer( console = Console() -@cli.command(help="Log in to Arcade Cloud", rich_help_panel="User") +@cli.command(help="Log in to Arcade", rich_help_panel="User") def login( host: str = typer.Option( - PROD_CLOUD_HOST, + PROD_COORDINATOR_HOST, "-h", "--host", - help="The Arcade Cloud host to log in to.", + help="The Arcade Coordinator host to log in to.", ), port: Optional[int] = typer.Option( None, "-p", "--port", - help="The port of the Arcade Cloud host (if running locally).", - ), - callback_host: str = typer.Option( - None, - "--callback-host", - help="The host to use to complete the auth flow - this should be the same as the host that the CLI is running on. Include the port if needed.", + help="The port of the Arcade Coordinator host (if running locally).", ), debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"), ) -> None: """ - Logs the user into Arcade Cloud. + Logs the user into Arcade using OAuth. """ - if check_existing_login(): console.print("\nTo log out and delete your locally-stored credentials, use ", end="") console.print("arcade logout", style="bold green", end="") console.print(".\n") return - # Start the HTTP server in a new thread - state = str(uuid.uuid4()) - auth_server = LocalAuthCallbackServer(state) - server_thread = threading.Thread(target=auth_server.run_server) - server_thread.start() + coordinator_url = build_coordinator_url(host, port) try: - # Open the browser for user login - login_url = compute_login_url(host, state, port, callback_host) + result = perform_oauth_login( + coordinator_url, + on_status=lambda msg: console.print(msg, style="dim"), + ) - console.print("Opening a browser to log you in...") - if not webbrowser.open(login_url): + # Save credentials + save_credentials_from_whoami(result.tokens, result.whoami, coordinator_url) + + # Success message + console.print(f"\n✅ Logged in as {result.email}.", style="bold green") + if result.selected_org and result.selected_project: console.print( - f"If a browser doesn't open automatically, copy this URL and paste it into your browser: {login_url}", + f"\nActive project: {result.selected_org.name} / {result.selected_project.name}", style="dim", ) + console.print( + "Run 'arcade org list' or 'arcade project list' to see available options.", + style="dim", + ) - # Wait for the server thread to finish - server_thread.join() + except OAuthLoginError as e: + if debug: + console.print(f"Debug: {e.__cause__}", style="dim") + handle_cli_error(str(e), should_exit=False) except KeyboardInterrupt: - auth_server.shutdown_server() + console.print("\nLogin cancelled.", style="yellow") except Exception as e: handle_cli_error("Login failed", e, debug) - finally: - if server_thread.is_alive(): - server_thread.join() # Ensure the server thread completes and cleans up -@cli.command(help="Log out of Arcade Cloud", rich_help_panel="User") +@cli.command(help="Log out of Arcade", rich_help_panel="User") def logout( debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"), ) -> None: """ - Logs the user out of Arcade Cloud. + Logs the user out of Arcade. """ try: # If the credentials file exists, delete it @@ -151,6 +151,55 @@ def logout( handle_cli_error("Logout failed", e, debug) +@cli.command(help="Show current login status and active context", rich_help_panel="User") +def whoami( + debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"), +) -> None: + """ + Display the current logged-in user and active organization/project. + """ + from arcade_core.config_model import Config + + try: + config = Config.load_from_file() + except Exception as e: + handle_cli_error("Failed to read credentials", e, debug) + return + + # Defensive - should not happen, because the main() callback prevents this: + if not config.auth: + console.print("Not logged in. Run 'arcade login' to authenticate.", style="bold red") + return + + email = config.user.email if config.user else "unknown" + console.print(f"Logged in as: {email}", style="bold green") + + if config.context: + console.print(f"\nActive organization: {config.context.org_name}", style="bold") + console.print(f" ID: {config.context.org_id}", style="dim") + console.print(f"\nActive project: {config.context.project_name}", style="bold") + console.print(f" ID: {config.context.project_id}", style="dim") + else: + console.print("\nNo active organization/project set.", style="yellow") + + console.print("\nRun 'arcade org list' or 'arcade project list' to see options.", style="dim") + + +cli.add_typer( + org_app, + name="org", + help="Manage organizations (list, set active)", + rich_help_panel="User", +) + +cli.add_typer( + project_app, + name="project", + help="Manage projects (list, set active)", + rich_help_panel="User", +) + + @cli.command( help="Create a new server package directory. Example usage: `arcade new my_mcp_server`", rich_help_panel="Build", @@ -714,8 +763,7 @@ def dashboard( dashboard_url = f"{base_url}/dashboard" # Try to hit /health endpoint on engine and warn if it is down - config = validate_and_get_config() - with Arcade(api_key=config.api.key, base_url=base_url) as client: + with Arcade(api_key="", base_url=base_url) as client: log_engine_health(client) # Open the dashboard in a browser @@ -755,5 +803,22 @@ def main_callback( if ctx.invoked_subcommand in public_commands: return + if _credentials_file_contains_legacy(): + console.print( + "\nYour credentials are from an older CLI version and are no longer supported.", + style="bold yellow", + ) + console.print( + "Run `arcade logout` to remove the old credentials, " + "then run `arcade login` to sign back in.", + style="bold yellow", + ) + console.print( + "\nNote: `arcade logout` will delete your API key from ~/.arcade/credentials.yaml. " + "If you need to preserve it, copy it before logging out.", + style="bold yellow", + ) + handle_cli_error("Legacy credentials detected. Please re-authenticate.") + if not check_existing_login(suppress_message=True): handle_cli_error("Not logged in to Arcade CLI. Use `arcade login` to log in.") diff --git a/libs/arcade-cli/arcade_cli/org.py b/libs/arcade-cli/arcade_cli/org.py new file mode 100644 index 00000000..fd870fdf --- /dev/null +++ b/libs/arcade-cli/arcade_cli/org.py @@ -0,0 +1,163 @@ +import typer +from arcade_core.constants import PROD_COORDINATOR_HOST +from rich.console import Console + +from arcade_cli.authn import ( + fetch_organizations, + fetch_projects, + select_default_project, +) +from arcade_cli.usage.command_tracker import TrackedTyper, TrackedTyperGroup +from arcade_cli.utils import ( + compute_base_url, + handle_cli_error, +) + +console = Console() + + +app = TrackedTyper( + cls=TrackedTyperGroup, + add_completion=False, + no_args_is_help=True, + pretty_exceptions_enable=True, + pretty_exceptions_show_locals=False, + pretty_exceptions_short=True, +) + +state = { + "coordinator_url": compute_base_url( + force_tls=False, + force_no_tls=False, + host=PROD_COORDINATOR_HOST, + port=None, + default_port=None, + ) +} + + +@app.callback() +def main( + host: str = typer.Option( + PROD_COORDINATOR_HOST, + "--host", + "-h", + help="The Arcade Coordinator host.", + ), + port: int = typer.Option( + None, + "--port", + "-p", + help="The port of the Arcade Coordinator host.", + ), + force_tls: bool = typer.Option( + False, + "--tls", + help="Whether to force TLS for the connection to Arcade Coordinator.", + ), + force_no_tls: bool = typer.Option( + False, + "--no-tls", + help="Whether to disable TLS for the connection to Arcade Coordinator.", + ), +) -> None: + """Configure Coordinator connection options for organization commands.""" + coordinator_url = compute_base_url(force_tls, force_no_tls, host, port, default_port=None) + state["coordinator_url"] = coordinator_url + + +@app.command("list", help="List organizations you belong to") +def org_list( + debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"), +) -> None: + """List all organizations the current user belongs to.""" + + from arcade_core.config_model import Config + from rich.table import Table + + try: + coordinator_url = state["coordinator_url"] + orgs = fetch_organizations(coordinator_url) + + if not orgs: + console.print("No organizations found.", style="yellow") + return + + # Get current active org + config = Config.load_from_file() + active_org_id = config.get_active_org_id() + + table = Table() + table.add_column("Name", style="cyan") + table.add_column("ID", style="dim") + table.add_column("Default", style="green") + table.add_column("Active", style="bold yellow") + + for org in orgs: + is_active = "✓" if org.org_id == active_org_id else "" + is_default = "✓" if org.is_default else "" + table.add_row(org.name, org.org_id, is_default, is_active) + + console.print(table) + console.print("\nUse 'arcade org set ' to switch organizations.\n") + + except ValueError as e: + handle_cli_error(str(e)) + except Exception as e: + handle_cli_error("Failed to list organizations", e, debug) + + +@app.command("set", help="Set the active organization") +def org_set( + org_id: str = typer.Argument(..., help="Organization ID to set as active"), + debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"), +) -> None: + """Set the active organization and reset project to its default.""" + + from arcade_core.config_model import Config, ContextConfig + + try: + coordinator_url = state["coordinator_url"] + + # Verify org exists and user has access + orgs = fetch_organizations(coordinator_url) + target_org = next((o for o in orgs if o.org_id == org_id), None) + + if not target_org: + console.print( + f"Organization '{org_id}' not found or you don't have access.", style="bold red" + ) + console.print("Run 'arcade org list' to see available organizations.", style="dim") + return + + # Fetch projects and select default + projects = fetch_projects(coordinator_url, org_id) + if not projects: + handle_cli_error( + f"No projects found in organization '{target_org.name}'. " + "Contact support@arcade.dev for assistance." + ) + return + + selected_project = select_default_project(projects) + if not selected_project: + handle_cli_error("Could not select a default project.") + return + + # Update config + config = Config.load_from_file() + config.context = ContextConfig( + org_id=target_org.org_id, + org_name=target_org.name, + project_id=selected_project.project_id, + project_name=selected_project.name, + ) + config.save_to_file() + + console.print(f"✓ Switched to organization: {target_org.name}", style="bold green") + console.print(f" Active project: {selected_project.name}", style="dim") + + except ValueError as e: + handle_cli_error(str(e)) + except Exception as e: + handle_cli_error("Failed to set organization", e, debug) diff --git a/libs/arcade-cli/arcade_cli/project.py b/libs/arcade-cli/arcade_cli/project.py new file mode 100644 index 00000000..2104e8c9 --- /dev/null +++ b/libs/arcade-cli/arcade_cli/project.py @@ -0,0 +1,156 @@ +import typer +from arcade_core.constants import PROD_COORDINATOR_HOST +from rich.console import Console + +from arcade_cli.authn import fetch_projects +from arcade_cli.usage.command_tracker import TrackedTyper, TrackedTyperGroup +from arcade_cli.utils import ( + compute_base_url, + handle_cli_error, +) + +console = Console() + + +app = TrackedTyper( + cls=TrackedTyperGroup, + add_completion=False, + no_args_is_help=True, + pretty_exceptions_enable=True, + pretty_exceptions_show_locals=False, + pretty_exceptions_short=True, +) + +state = { + "coordinator_url": compute_base_url( + force_tls=False, + force_no_tls=False, + host=PROD_COORDINATOR_HOST, + port=None, + default_port=None, + ) +} + + +@app.callback() +def main( + host: str = typer.Option( + PROD_COORDINATOR_HOST, + "--host", + "-h", + help="The Arcade Coordinator host.", + ), + port: int = typer.Option( + None, + "--port", + "-p", + help="The port of the Arcade Coordinator host.", + ), + force_tls: bool = typer.Option( + False, + "--tls", + help="Whether to force TLS for the connection to Arcade Coordinator.", + ), + force_no_tls: bool = typer.Option( + False, + "--no-tls", + help="Whether to disable TLS for the connection to Arcade Coordinator.", + ), +) -> None: + """Configure Coordinator connection options for project commands.""" + coordinator_url = compute_base_url(force_tls, force_no_tls, host, port, default_port=None) + state["coordinator_url"] = coordinator_url + + +@app.command("list", help="List projects in the active organization") +def project_list( + debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"), +) -> None: + """List all projects in the current active organization.""" + from arcade_core.config_model import Config + from rich.table import Table + + try: + config = Config.load_from_file() + + if not config.context: + console.print("No active organization set. Run 'arcade login' first.", style="bold red") + return + + coordinator_url = state["coordinator_url"] + projects = fetch_projects(coordinator_url, config.context.org_id) + + if not projects: + console.print( + f"No projects found in organization '{config.context.org_name}'.", + style="yellow", + ) + return + + active_project_id = config.get_active_project_id() + + console.print( + f"\nActive organization: {config.context.org_name}\n" + "Use 'arcade org list' and 'arcade org set ' to switch organizations.\n", + ) + + table = Table() + table.add_column("Name", style="cyan") + table.add_column("ID", style="dim") + table.add_column("Default", style="green") + table.add_column("Active", style="bold yellow") + + for project in projects: + is_active = "✓" if project.project_id == active_project_id else "" + is_default = "✓" if project.is_default else "" + table.add_row(project.name, project.project_id, is_default, is_active) + + console.print(table) + console.print("\nUse 'arcade project set ' to switch projects.\n") + + except ValueError as e: + handle_cli_error(str(e)) + except Exception as e: + handle_cli_error("Failed to list projects", e, debug) + + +@app.command("set", help="Set the active project") +def project_set( + project_id: str = typer.Argument(..., help="Project ID to set as active"), + debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"), +) -> None: + """Set the active project within the current organization.""" + from arcade_core.config_model import Config + + try: + config = Config.load_from_file() + + if not config.context: + console.print("No active organization set. Run 'arcade login' first.", style="bold red") + return + + coordinator_url = state["coordinator_url"] + + # Verify project exists in current org + projects = fetch_projects(coordinator_url, config.context.org_id) + target_project = next((p for p in projects if p.project_id == project_id), None) + + if not target_project: + console.print( + f"Project '{project_id}' not found in organization '{config.context.org_name}'.", + style="bold red", + ) + console.print("Run 'arcade project list' to see available projects.", style="dim") + return + + # Update config + config.context.project_id = target_project.project_id + config.context.project_name = target_project.name + config.save_to_file() + + console.print(f"✓ Switched to project: {target_project.name}", style="bold green") + + except ValueError as e: + handle_cli_error(str(e)) + except Exception as e: + handle_cli_error("Failed to set project", e, debug) diff --git a/libs/arcade-cli/arcade_cli/secret.py b/libs/arcade-cli/arcade_cli/secret.py index 29b2fb75..47a72cfd 100644 --- a/libs/arcade-cli/arcade_cli/secret.py +++ b/libs/arcade-cli/arcade_cli/secret.py @@ -1,15 +1,14 @@ import httpx import typer +from arcade_core.constants import PROD_ENGINE_HOST from rich.console import Console from rich.table import Table -from arcade_cli.constants import ( - PROD_ENGINE_HOST, -) from arcade_cli.usage.command_tracker import TrackedTyper, TrackedTyperGroup from arcade_cli.utils import ( compute_base_url, - validate_and_get_config, + get_auth_headers, + get_org_scoped_url, ) console = Console() @@ -96,8 +95,6 @@ def set_secret( if from_env and key_value_pairs: raise typer.BadParameter("Cannot use both KEY=VALUE pairs and --from-env at the same time.") - config = validate_and_get_config() - if from_env: secrets = load_env_file(env_file) else: @@ -116,11 +113,9 @@ def set_secret( value = value # keep the value as is, including the whitespace secrets[key] = value - engine_url = state["engine_url"] - for secret_key, secret_value in secrets.items(): try: - _upsert_secret_to_engine(engine_url, config.api.key, secret_key, secret_value) + _upsert_secret(secret_key, secret_value) except Exception as e: console.print(f"Error setting secret '{secret_key}': {e}", style="bold red") continue @@ -129,13 +124,10 @@ def set_secret( ) -@app.command("list", help="List all tool secrets in Arcade Cloud") +@app.command("list", help="List all tool secrets in Arcade") def list_secrets() -> None: """List all secrets (keys only, values are masked).""" - config = validate_and_get_config() - engine_url = state["engine_url"] - - secrets = _get_secrets_from_engine(engine_url, config.api.key) + secrets = _get_secrets() print_secret_table(secrets) @@ -147,9 +139,7 @@ def unset_secret( ), ) -> None: """Delete tool secrets.""" - config = validate_and_get_config() - engine_url = state["engine_url"] - secrets = _get_secrets_from_engine(engine_url, config.api.key) + secrets = _get_secrets() key_to_id = {secret["key"]: secret["id"] for secret in secrets} @@ -160,7 +150,7 @@ def unset_secret( continue try: - _delete_secret_from_engine(engine_url, config.api.key, secret_id) + _delete_secret(secret_id) console.print(f"Secret '{key}' deleted successfully") except Exception: console.print( @@ -258,29 +248,30 @@ def _remove_inline_comment(value: str) -> str: return value -def _upsert_secret_to_engine( - engine_url: str, api_key: str, secret_id: str, secret_value: str -) -> None: +def _upsert_secret(secret_key: str, secret_value: str) -> None: + """Upsert a secret to the engine.""" + engine_url = state["engine_url"] + url = get_org_scoped_url(engine_url, f"/secrets/{secret_key}") response = httpx.put( - f"{engine_url}/v1/admin/secrets/{secret_id}", - headers={"Authorization": f"Bearer {api_key}"}, + url, + headers=get_auth_headers(), json={"description": "Secret set via CLI", "value": secret_value}, ) response.raise_for_status() -def _get_secrets_from_engine(engine_url: str, api_key: str) -> list[dict]: - response = httpx.get( - f"{engine_url}/v1/admin/secrets", - headers={"Authorization": f"Bearer {api_key}"}, - ) +def _get_secrets() -> list[dict]: + """Get all secrets from the engine.""" + engine_url = state["engine_url"] + url = get_org_scoped_url(engine_url, "/secrets") + response = httpx.get(url, headers=get_auth_headers()) response.raise_for_status() return response.json()["items"] # type: ignore[no-any-return] -def _delete_secret_from_engine(engine_url: str, api_key: str, secret_id: str) -> None: - response = httpx.delete( - f"{engine_url}/v1/admin/secrets/{secret_id}", - headers={"Authorization": f"Bearer {api_key}"}, - ) +def _delete_secret(secret_id: str) -> None: + """Delete a secret from the engine.""" + engine_url = state["engine_url"] + url = get_org_scoped_url(engine_url, f"/secrets/{secret_id}") + response = httpx.delete(url, headers=get_auth_headers()) response.raise_for_status() diff --git a/libs/arcade-cli/arcade_cli/server.py b/libs/arcade-cli/arcade_cli/server.py index bdf2cfab..592c48a4 100644 --- a/libs/arcade-cli/arcade_cli/server.py +++ b/libs/arcade-cli/arcade_cli/server.py @@ -6,20 +6,20 @@ from typing import Optional import httpx import typer -from arcadepy import Arcade, NotFoundError +from arcade_core.constants import PROD_ENGINE_HOST +from arcadepy import NotFoundError from arcadepy.types import WorkerHealthResponse, WorkerResponse from dateutil import parser from rich.console import Console from rich.table import Table -from arcade_cli.constants import ( - PROD_ENGINE_HOST, -) from arcade_cli.usage.command_tracker import TrackedTyper, TrackedTyperGroup from arcade_cli.utils import ( compute_base_url, + get_arcade_client, + get_auth_headers, + get_org_scoped_url, handle_cli_error, - validate_and_get_config, ) console = Console() @@ -159,9 +159,8 @@ def list_servers( help="Show debug information", ), ) -> None: - config = validate_and_get_config() base_url = state["engine_url"] - client = Arcade(api_key=config.api.key, base_url=base_url) + client = get_arcade_client(base_url) try: servers = client.workers.list(limit=100) _print_servers_table(servers.items) @@ -179,9 +178,8 @@ def get_server( help="Show debug information", ), ) -> None: - config = validate_and_get_config() base_url = state["engine_url"] - client = Arcade(api_key=config.api.key, base_url=base_url) + client = get_arcade_client(base_url) try: server = client.workers.get(server_name) server_health = client.workers.health(server_name) @@ -200,9 +198,8 @@ def enable_server( help="Show debug information", ), ) -> None: - config = validate_and_get_config() engine_url = state["engine_url"] - arcade = Arcade(api_key=config.api.key, base_url=engine_url) + arcade = get_arcade_client(engine_url) try: arcade.workers.update(server_name, enabled=True) except Exception as e: @@ -219,9 +216,8 @@ def disable_server( help="Show debug information", ), ) -> None: - config = validate_and_get_config() engine_url = state["engine_url"] - arcade = Arcade(api_key=config.api.key, base_url=engine_url) + arcade = get_arcade_client(engine_url) try: arcade.workers.update(server_name, enabled=False) except Exception as e: @@ -238,11 +234,10 @@ def delete_server( help="Show debug information", ), ) -> None: - config = validate_and_get_config() engine_url = state["engine_url"] try: - arcade = Arcade(api_key=config.api.key, base_url=engine_url) + arcade = get_arcade_client(engine_url) arcade.workers.delete(server_name) console.print(f"✓ Server '{server_name}' deleted successfully", style="green") except NotFoundError as e: @@ -287,8 +282,8 @@ def get_server_logs( help="Show debug information", ), ) -> None: - config = validate_and_get_config() - headers = {"Authorization": f"Bearer {config.api.key}", "Content-Type": "application/json"} + auth_headers = get_auth_headers() + headers = {**auth_headers, "Content-Type": "application/json"} # Set defaults based on whether we're following or not if since is None: @@ -307,14 +302,16 @@ def get_server_logs( except ValueError as e: handle_cli_error(f"Invalid time format: {e}", debug=debug) + base_url = state["engine_url"] + if follow: # Use the streaming endpoint - engine_url = state["engine_url"] + f"/v1/deployments/{server_name}/logs/stream" - asyncio.run(_stream_deployment_logs(engine_url, headers, since_dt, until_dt, debug=debug)) + logs_url = get_org_scoped_url(base_url, f"/deployments/{server_name}/logs/stream") + asyncio.run(_stream_deployment_logs(logs_url, headers, since_dt, until_dt, debug=debug)) else: # Use the non-streaming endpoint - engine_url = state["engine_url"] + f"/v1/deployments/{server_name}/logs" - _display_deployment_logs(engine_url, headers, since_dt, until_dt, debug=debug) + logs_url = get_org_scoped_url(base_url, f"/deployments/{server_name}/logs") + _display_deployment_logs(logs_url, headers, since_dt, until_dt, debug=debug) def _display_deployment_logs( diff --git a/libs/arcade-cli/arcade_cli/templates/base.jinja b/libs/arcade-cli/arcade_cli/templates/base.jinja new file mode 100644 index 00000000..c18f73e5 --- /dev/null +++ b/libs/arcade-cli/arcade_cli/templates/base.jinja @@ -0,0 +1,152 @@ + + + + + + {% block title %}{% endblock %} + + + + + + + + {% block scripts %}{% endblock %} + + +
+
+ + {% set heading_block %}{% block heading %}{% endblock %}{% endset %} + {% if heading_block | trim %} +

{{ heading_block | trim | safe }}

+ {% endif %} + {% block content %}{% endblock %} +
+
+ + + diff --git a/libs/arcade-cli/arcade_cli/templates/cli_login_failed.jinja b/libs/arcade-cli/arcade_cli/templates/cli_login_failed.jinja new file mode 100644 index 00000000..b2b6cfbd --- /dev/null +++ b/libs/arcade-cli/arcade_cli/templates/cli_login_failed.jinja @@ -0,0 +1,152 @@ +{% extends "base.jinja" %} + +{% block title %}Arcade.dev - Authorization failed{% endblock %} + +{% block styles %} +.arc-stack { + display: flex; + flex-direction: column; + gap: 24px; + text-align: center; + align-items: center; +} +.arc-stack p { + color: var(--text-secondary, #d6d6d6); +} +.message-error { + color: #ff4d4d; + font-weight: 500; +} +.developer-section { + display: flex; + flex-direction: column; + gap: 16px; + align-items: center; + width: 100%; +} +.developer-toggle { + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--border-default, #2f2f2f), transparent 10%); + background: transparent; + color: var(--text-secondary, #d6d6d6); + font-weight: 500; + font-size: 0.875rem; + padding: 10px 20px; + cursor: pointer; + transition: all 0.2s ease; +} +.developer-toggle:hover { + border-color: color-mix(in srgb, var(--border-default, #2f2f2f), #ffffff 15%); + background: rgba(255, 255, 255, 0.03); +} +.developer-details { + width: 100%; + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.3s ease, opacity 0.3s ease; + opacity: 0; +} +.developer-details.show { + grid-template-rows: 1fr; + opacity: 1; +} +.developer-details-inner { + overflow: hidden; + display: flex; + flex-direction: column; + gap: 16px; +} +.arc-panel { + padding: clamp(16px, 4vw, 24px); + border-radius: 12px; + background: color-mix(in srgb, var(--surface-card, #141414) 85%, transparent); + border: 1px solid color-mix(in srgb, var(--border-default, #2d2d2d), transparent 25%); + text-align: left; +} +.arc-panel pre { + margin: 0; + font-family: 'JetBrains Mono', 'SFMono-Regular', monospace; + font-size: 0.8rem; + color: var(--text-secondary, #d6d6d6); + white-space: pre-wrap; + word-break: break-word; +} +.error-label { + color: var(--text-muted, #999); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 4px; +} +.error-value { + color: var(--text-primary, #fff); + font-family: 'JetBrains Mono', 'SFMono-Regular', monospace; + font-size: 0.85rem; +} +.error-block { + margin-bottom: 12px; +} +.error-block:last-child { + margin-bottom: 0; +} +{% endblock %} + +{% block scripts %} + +{% endblock %} + +{% block content %} +
+

Authorization failed

+

{{ message if message else 'Something went wrong. Please close this window and try again.' }}

+ +
+

Powered by Arcade.dev

+
+ + {% if error or error_description %} +
+ +
+
+
+ {% if error %} +
+
Error
+
{{ error }}
+
+ {% endif %} + {% if error_description %} +
+
Description
+
{{ error_description }}
+
+ {% endif %} + {% if state %} +
+
State
+
{{ state }}
+
+ {% endif %} +
+
+
+
+ {% endif %} +
+{% endblock %} diff --git a/libs/arcade-cli/arcade_cli/templates/cli_login_success.jinja b/libs/arcade-cli/arcade_cli/templates/cli_login_success.jinja new file mode 100644 index 00000000..3bea178d --- /dev/null +++ b/libs/arcade-cli/arcade_cli/templates/cli_login_success.jinja @@ -0,0 +1,28 @@ +{% extends "base.jinja" %} + +{% block title %}Arcade.dev - Authorization successful{% endblock %} + +{% block styles %} +.arc-stack { + display: flex; + flex-direction: column; + gap: 24px; + text-align: center; + align-items: center; +} +.arc-stack p { + color: var(--text-secondary, #d6d6d6); +} +{% endblock %} + +{% block content %} +
+

Authorization successful.

+

You can close this window and return to your terminal.

+ +
+

Powered by Arcade.dev

+
+
+{% endblock %} + diff --git a/libs/arcade-cli/arcade_cli/usage/command_tracker.py b/libs/arcade-cli/arcade_cli/usage/command_tracker.py index 5dc1ad92..ba5633cc 100644 --- a/libs/arcade-cli/arcade_cli/usage/command_tracker.py +++ b/libs/arcade-cli/arcade_cli/usage/command_tracker.py @@ -12,6 +12,8 @@ from arcade_cli.usage.constants import ( EVENT_CLI_COMMAND_FAILED, PROP_CLI_VERSION, PROP_COMMAND_NAME, + PROP_ORG_ID, + PROP_PROJECT_ID, ) from arcade_core.constants import ARCADE_CONFIG_PATH from arcade_core.usage import UsageIdentity, UsageService, is_tracking_enabled @@ -81,6 +83,24 @@ class CommandTracker: current_ctx = current_ctx.parent return ".".join(reversed(command_parts)) + def _get_org_project_context(self) -> tuple[str | None, str | None]: + """Get org_id and project_id from config if available.""" + try: + from arcade_core.config_model import Config + + config = Config.load_from_file() + if config.context: + return config.context.org_id, config.context.project_id + except FileNotFoundError: + # No config file - user isn't logged in, which is fine + pass + except Exception as e: + console.print( + f"[yellow]Warning: Failed to load Arcade config: {e}[/yellow]\n" + "[yellow]Run 'arcade logout' then 'arcade login' to fix this.[/yellow]" + ) + return None, None + def _handle_successful_login(self) -> None: """Handle a successful login event. @@ -196,6 +216,13 @@ class CommandTracker: PROP_OS_RELEASE: platform.release(), } + # Add org/project context when available (many commands operate within a project) + org_id, project_id = self._get_org_project_context() + if org_id: + properties[PROP_ORG_ID] = org_id + if project_id: + properties[PROP_PROJECT_ID] = project_id + if not success and error_message: properties[PROP_ERROR_MESSAGE] = error_message diff --git a/libs/arcade-cli/arcade_cli/usage/constants.py b/libs/arcade-cli/arcade_cli/usage/constants.py index 651084c8..f092a13f 100644 --- a/libs/arcade-cli/arcade_cli/usage/constants.py +++ b/libs/arcade-cli/arcade_cli/usage/constants.py @@ -5,3 +5,5 @@ EVENT_CLI_COMMAND_FAILED = "CLI execution failed" # CLI Specific Property Names PROP_COMMAND_NAME = "command_name" PROP_CLI_VERSION = "cli_version" +PROP_ORG_ID = "org_id" +PROP_PROJECT_ID = "project_id" diff --git a/libs/arcade-cli/arcade_cli/utils.py b/libs/arcade-cli/arcade_cli/utils.py index d3a94546..256be8e5 100644 --- a/libs/arcade-cli/arcade_cli/utils.py +++ b/libs/arcade-cli/arcade_cli/utils.py @@ -12,11 +12,12 @@ from importlib import metadata from pathlib import Path from textwrap import dedent from typing import Any, Callable, Union, cast -from urllib.parse import urlencode, urlparse +from urllib.parse import urlparse import idna from arcade_core import ToolCatalog, Toolkit from arcade_core.config_model import Config +from arcade_core.constants import LOCALHOST from arcade_core.discovery import ( analyze_files_for_tools, build_minimal_toolkit, @@ -24,6 +25,7 @@ from arcade_core.discovery import ( find_candidate_tool_files, ) from arcade_core.errors import ToolkitLoadError +from arcade_core.network.org_transport import build_org_scoped_http_client from arcade_core.schema import ToolDefinition from arcadepy import ( NOT_GIVEN, @@ -48,8 +50,6 @@ from rich.text import Text from typer.core import TyperGroup from typer.models import Context -from arcade_cli.constants import LOCAL_AUTH_CALLBACK_PORT, LOCALHOST - console = Console() @@ -203,9 +203,10 @@ def compute_base_url( force_no_tls: bool, host: str, port: int | None, + default_port: int | None = 9099, ) -> str: """ - Compute the base URL for the Arcade Engine from the provided overrides. + Compute the base URL for an Arcade service from the provided overrides. Treats 127.0.0.1 and 0.0.0.0 as aliases for localhost. @@ -217,8 +218,8 @@ def compute_base_url( hostnames with underscores. This property exists to provide a consistent and correctly formatted URL for - connecting to the Arcade Engine, taking into account various configuration - options and edge cases. It ensures that: + connecting to Arcade services (Engine, Coordinator), taking into account various + configuration options and edge cases. It ensures that: 1. The correct protocol (http/https) is used based on the TLS setting. 2. IPv4 and IPv6 addresses are properly formatted. @@ -228,10 +229,16 @@ def compute_base_url( 6. Hostnames with underscores (common in development environments) are supported. 7. Pre-existing port specifications in the host are respected. - The resulting URL is always suffixed with api_version to specify the API version. + Args: + force_tls: Force HTTPS protocol. + force_no_tls: Force HTTP protocol (takes precedence over force_tls). + host: The hostname or IP address. + port: The port number (optional). + default_port: The default port for localhost if none specified. + Use 9099 for Engine, None for Coordinator (standard HTTPS). Returns: - str: The fully constructed URL for the Arcade Engine. + str: The fully constructed URL for the Arcade service. """ # "Use 127.0.0.1" and "0.0.0.0" as aliases for "localhost" host = LOCALHOST if host in ["127.0.0.1", "0.0.0.0"] else host # noqa: S104 @@ -244,9 +251,9 @@ def compute_base_url( else: is_tls = host != LOCALHOST - # "localhost" defaults to dev port if not specified - if host == LOCALHOST and port is None: - port = 9099 + # "localhost" defaults to dev port if not specified and a default is provided + if host == LOCALHOST and port is None and default_port is not None: + port = default_port protocol = "https" if is_tls else "http" @@ -284,39 +291,6 @@ def compute_base_url( return f"{protocol}://{encoded_host}" -def compute_login_url( - host: str, state: str, port: int | None, callback_host: str | None = None -) -> str: - """ - Compute the full URL for the CLI login endpoint. - """ - if callback_host: - if not (callback_host.startswith("http://") or callback_host.startswith("https://")): - callback_uri = f"http://{callback_host}" - else: - callback_uri = callback_host - if not callback_uri.rstrip("/").endswith("/callback"): - if port: - callback_uri = callback_uri.rstrip("/") + f":{port}" + "/callback" - else: - callback_uri = callback_uri.rstrip("/") + "/callback" - else: - callback_uri = f"http://{LOCALHOST}:{LOCAL_AUTH_CALLBACK_PORT}/callback" - - params = urlencode({"callback_uri": callback_uri, "state": state}) - - port = port if port else 8000 - - login_base_url = ( - f"http://{LOCALHOST}:{port}" - if host in [LOCALHOST, "127.0.0.1", "0.0.0.0"] # noqa: S104 - else f"https://{host}" - ) - endpoint = "/api/v1/auth/cli_login" - - return f"{login_base_url}{endpoint}?{params}" - - def get_tools_from_engine( host: str, port: int | None = None, @@ -324,9 +298,8 @@ def get_tools_from_engine( force_no_tls: bool = False, toolkit: str | None = None, ) -> list[ToolDefinition]: - config = validate_and_get_config() base_url = compute_base_url(force_tls, force_no_tls, host, port) - client = Arcade(api_key=config.api.key, base_url=base_url) + client = get_arcade_client(base_url) tools = [] try: @@ -422,18 +395,15 @@ def validate_and_get_config( validate_user: bool = True, ) -> Config: """ - Validates the configuration, user, and returns the Config object + Validates the configuration, user, and returns the Config object. """ - try: from arcade_core.config import config except Exception as e: handle_cli_error("Not logged in", e, debug=False) - if validate_api and (not config.api or not config.api.key): - handle_cli_error( - "API configuration not found or key is missing. Please run `arcade login`." - ) + if validate_api and not config.auth: + handle_cli_error("Authentication not configured. Please run `arcade login`.") if validate_user and (not config.user or not config.user.email): handle_cli_error("User email not found in configuration. Please run `arcade login`.") @@ -441,6 +411,120 @@ def validate_and_get_config( return config +def get_org_project_context() -> tuple[str, str]: + """ + Get the active org_id and project_id from config. + + Returns: + Tuple of (org_id, project_id) + + Raises: + CLIError if no active org/project context is set. + """ + config = validate_and_get_config() + + if not config.context or not config.context.org_id or not config.context.project_id: + handle_cli_error("No active organization/project set. Please run `arcade login` first.") + raise AssertionError("unreachable") # handle_cli_error raises CLIError + + return config.context.org_id, config.context.project_id + + +def get_auth_headers(coordinator_url: str | None = None) -> dict[str, str]: + """ + Get authorization headers for API calls. + + Args: + coordinator_url: Coordinator URL for token refresh (optional for legacy) + + Returns: + Dictionary with Authorization header + """ + from arcade_core.constants import PROD_COORDINATOR_HOST + + from arcade_cli.authn import get_valid_access_token + + config = validate_and_get_config() + resolved_coordinator_url = ( + coordinator_url + or (getattr(config, "coordinator_url", None) or None) + or f"https://{PROD_COORDINATOR_HOST}" + ) + + try: + access_token = get_valid_access_token(resolved_coordinator_url) + except ValueError as e: + handle_cli_error(str(e)) + raise AssertionError("unreachable") # handle_cli_error raises CLIError + + return {"Authorization": f"Bearer {access_token}"} + + +def get_org_scoped_url(base_url: str, path: str) -> str: + """ + Build an org-scoped URL using the active context. + + Args: + base_url: Base URL of the API (e.g., https://api.arcade.dev) + path: Path suffix after the org/project prefix (e.g., "/secrets/KEY") + + Returns: + Full URL with org/project path prefix + + Raises: + CLIError if no active context is set + + Example: + get_org_scoped_url("https://api.arcade.dev", "/secrets/MY_KEY") + # Returns: "https://api.arcade.dev/v1/orgs/ORG_ID/projects/PROJECT_ID/secrets/MY_KEY" + """ + config = validate_and_get_config() + + if not config.context: + handle_cli_error("No active organization/project. Please run `arcade login` first.") + raise AssertionError("unreachable") # handle_cli_error raises CLIError + + org_id = config.context.org_id + project_id = config.context.project_id + + return f"{base_url}/v1/orgs/{org_id}/projects/{project_id}{path}" + + +def get_arcade_client(base_url: str) -> Arcade: + """ + Create an Arcade client with proper authentication and org-scoped URL rewriting. + Requests are automatically rewritten to include org/project scope in URLs. + + Args: + base_url: Base URL of the Arcade Engine + + Returns: + Configured Arcade client + + Example: + client = get_arcade_client("https://api.arcade.dev") + servers = client.workers.list() # Automatically uses org-scoped URLs + """ + config = validate_and_get_config() + + # OAuth mode: need to rewrite URLs to include org/project scope + from arcade_cli.authn import get_valid_access_token + + access_token = get_valid_access_token() + + # Get org/project context for URL rewriting + if not config.context or not config.context.org_id or not config.context.project_id: + handle_cli_error("No active organization/project set. Please run `arcade login` first.") + raise AssertionError("unreachable") # handle_cli_error raises CLIError + + org_id = config.context.org_id + project_id = config.context.project_id + + http_client = build_org_scoped_http_client(org_id, project_id) + + return Arcade(api_key=access_token, base_url=base_url, http_client=http_client) + + def log_engine_health(client: Arcade) -> None: try: result = client.health.check(timeout=2) diff --git a/libs/arcade-core/arcade_core/auth_tokens.py b/libs/arcade-core/arcade_core/auth_tokens.py new file mode 100644 index 00000000..bad2f60d --- /dev/null +++ b/libs/arcade-core/arcade_core/auth_tokens.py @@ -0,0 +1,110 @@ +""" +Shared access-token utilities used by both the CLI and MCP server. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta + +import httpx +from pydantic import BaseModel + +from arcade_core.config_model import Config +from arcade_core.constants import PROD_COORDINATOR_HOST + + +class CLIConfig(BaseModel): + """OAuth configuration returned by the Coordinator.""" + + client_id: str + authorization_endpoint: str + token_endpoint: str + + +class TokenResponse(BaseModel): + """OAuth token response.""" + + access_token: str + refresh_token: str + expires_in: int + token_type: str + + +def fetch_cli_config(coordinator_url: str) -> CLIConfig: + """Fetch OAuth configuration from the Coordinator.""" + url = f"{coordinator_url}/api/v1/auth/cli_config" + response = httpx.get(url, timeout=30) + response.raise_for_status() + return CLIConfig.model_validate(response.json()) + + +def refresh_access_token( + cli_config: CLIConfig, + refresh_token: str, +) -> TokenResponse: + """Refresh the access token using authlib-compatible token endpoint.""" + response = httpx.post( + cli_config.token_endpoint, + data={ + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": cli_config.client_id, + }, + timeout=30, + ) + response.raise_for_status() + token = response.json() + return TokenResponse( + access_token=token["access_token"], + refresh_token=token.get("refresh_token", refresh_token), + expires_in=token["expires_in"], + token_type=token["token_type"], + ) + + +def get_valid_access_token(coordinator_url: str | None = None) -> str: + """ + Get a valid access token, refreshing if necessary. + + Returns: + Valid access token + + Raises: + ValueError: If not logged in or token refresh fails + """ + try: + config = Config.load_from_file() + except FileNotFoundError: + raise ValueError("Not logged in. Please run 'arcade login' first.") + + resolved_coordinator_url = ( + coordinator_url + or (config.coordinator_url if config.coordinator_url else None) + or f"https://{PROD_COORDINATOR_HOST}" + ) + + if not config.auth: + raise ValueError("Not logged in. Please run 'arcade login' first.") + + # Check if token needs refresh + if config.is_token_expired(): + cli_config = fetch_cli_config(resolved_coordinator_url) + + try: + new_tokens = refresh_access_token(cli_config, config.auth.refresh_token) + except httpx.HTTPError as e: + raise ValueError( + f"Failed to refresh token: {e}. Please run 'arcade login' to re-authenticate." + ) + + # Update stored credentials + expires_at = datetime.now() + timedelta(seconds=new_tokens.expires_in) + config.coordinator_url = resolved_coordinator_url + config.auth.access_token = new_tokens.access_token + config.auth.refresh_token = new_tokens.refresh_token + config.auth.expires_at = expires_at + config.save_to_file() + + return new_tokens.access_token + + return config.auth.access_token diff --git a/libs/arcade-core/arcade_core/config_model.py b/libs/arcade-core/arcade_core/config_model.py index 54e96db3..7080037c 100644 --- a/libs/arcade-core/arcade_core/config_model.py +++ b/libs/arcade-core/arcade_core/config_model.py @@ -1,4 +1,5 @@ import os +from datetime import datetime, timedelta from pathlib import Path from typing import Any @@ -10,18 +11,22 @@ class BaseConfig(BaseModel): model_config = ConfigDict(extra="ignore") -class ApiConfig(BaseConfig): +class AuthConfig(BaseConfig): """ - Arcade API configuration. + OAuth authentication configuration. """ - key: str + access_token: str """ - Arcade API key. + OAuth access token (JWT). """ - version: str = "v1" + refresh_token: str """ - Arcade API version. + OAuth refresh token for obtaining new access tokens. + """ + expires_at: datetime + """ + When the access token expires. """ @@ -36,15 +41,51 @@ class UserConfig(BaseConfig): """ -class Config(BaseConfig): +class ContextConfig(BaseConfig): """ - Configuration for Arcade. + Active organization and project context. """ - api: ApiConfig + org_id: str """ - Arcade API configuration. + Active organization ID. """ + org_name: str + """ + Active organization name. + """ + project_id: str + """ + Active project ID. + """ + project_name: str + """ + Active project name. + """ + + +class Config(BaseConfig): + """ + Configuration for Arcade CLI. + """ + + coordinator_url: str | None = None + """ + Base URL of the Arcade Coordinator used for authentication flows. + """ + + auth: AuthConfig | None = None + """ + OAuth authentication configuration. + """ + + # Active org/project context + context: ContextConfig | None = None + """ + Active organization and project context. + """ + + # User info user: UserConfig | None = None """ Arcade user configuration. @@ -53,6 +94,48 @@ class Config(BaseConfig): def __init__(self, **data: Any): super().__init__(**data) + def is_authenticated(self) -> bool: + """ + Check if the user is authenticated (has valid auth config). + """ + return self.auth is not None + + def is_token_expired(self) -> bool: + """ + Check if the access token is expired or will expire within 5 minutes. + """ + if not self.auth: + return True + # Consider expired if less than 5 minutes remaining + buffer_seconds = 300 + return datetime.now() >= self.auth.expires_at.replace(tzinfo=None) - timedelta( + seconds=buffer_seconds + ) + + def get_access_token(self) -> str | None: + """ + Get the current access token if available. + """ + if self.auth: + return self.auth.access_token + return None + + def get_active_org_id(self) -> str | None: + """ + Get the active organization ID. + """ + if self.context: + return self.context.org_id + return None + + def get_active_project_id(self) -> str | None: + """ + Get the active project ID. + """ + if self.context: + return self.context.project_id + return None + @classmethod def get_config_dir_path(cls) -> Path: """ @@ -82,16 +165,11 @@ class Config(BaseConfig): """ Load the configuration from the YAML file in the configuration directory. - If no configuration file exists, this method will create a new one with default values. - The default configuration includes: - - An empty API configuration - - A default Engine configuration (host: "api.arcade.dev", port: None, tls: True) - - No user configuration - Returns: - Config: The loaded or newly created configuration. + Config: The loaded configuration. Raises: + FileNotFoundError: If no configuration file exists. ValueError: If the existing configuration file is invalid. """ cls.ensure_config_dir_exists() @@ -108,13 +186,13 @@ class Config(BaseConfig): if config_data is None: raise ValueError( - "Invalid credentials.yaml file. Please ensure it is a valid YAML file or" + "Invalid credentials.yaml file. Please ensure it is a valid YAML file or " "run `arcade logout`, then `arcade login` to start from a clean slate." ) if "cloud" not in config_data: raise ValueError( - "Invalid credentials.yaml file. Expected a 'cloud' key." + "Invalid credentials.yaml file. Expected a 'cloud' key. " "Run `arcade logout`, then `arcade login` to start from a clean slate." ) @@ -123,7 +201,6 @@ class Config(BaseConfig): except ValidationError as e: # Get only the errors with {type:missing} and combine them # into a nicely-formatted string message. - # Any other errors without {type:missing} should just be str()ed missing_field_errors = [ ".".join(map(str, error["loc"])) for error in e.errors() @@ -145,7 +222,15 @@ class Config(BaseConfig): def save_to_file(self) -> None: """ Save the configuration to the YAML file in the configuration directory. + + Sets file permissions to 600 (owner read/write only) for security. """ Config.ensure_config_dir_exists() config_file_path = Config.get_config_file_path() - config_file_path.write_text(yaml.dump(self.model_dump())) + + # Convert to dict, excluding None values for cleaner output + data = {"cloud": self.model_dump(exclude_none=True, mode="json")} + config_file_path.write_text(yaml.dump(data, default_flow_style=False)) + + # Set restrictive permissions (owner read/write only) + config_file_path.chmod(0o600) diff --git a/libs/arcade-core/arcade_core/constants.py b/libs/arcade-core/arcade_core/constants.py index 7e749b0b..8554039c 100644 --- a/libs/arcade-core/arcade_core/constants.py +++ b/libs/arcade-core/arcade_core/constants.py @@ -4,3 +4,8 @@ import os ARCADE_CONFIG_PATH = os.path.join(os.path.expanduser(os.getenv("ARCADE_WORK_DIR", "~")), ".arcade") # The path to the file containing the user's Arcade-related credentials (e.g., ARCADE_API_KEY). CREDENTIALS_FILE_PATH = os.path.join(ARCADE_CONFIG_PATH, "credentials.yaml") + +# Host defaults used by both the CLI and MCP server +PROD_COORDINATOR_HOST = "cloud.arcade.dev" +PROD_ENGINE_HOST = "api.arcade.dev" +LOCALHOST = "localhost" diff --git a/libs/arcade-core/arcade_core/network/__init__.py b/libs/arcade-core/arcade_core/network/__init__.py new file mode 100644 index 00000000..f2be2777 --- /dev/null +++ b/libs/arcade-core/arcade_core/network/__init__.py @@ -0,0 +1 @@ +# Network utilities shared across Arcade components. diff --git a/libs/arcade-core/arcade_core/network/org_transport.py b/libs/arcade-core/arcade_core/network/org_transport.py new file mode 100644 index 00000000..55bdb0e2 --- /dev/null +++ b/libs/arcade-core/arcade_core/network/org_transport.py @@ -0,0 +1,99 @@ +""" +Shared HTTP transport helpers for org-scoped Arcade API access. +""" + +from __future__ import annotations + +import httpx + + +def _rewrite_request_path(request: httpx.Request, org_id: str, project_id: str) -> httpx.Request: + """Return a request with its path rewritten to include org/project scope.""" + path = request.url.path + if path.startswith("/v1/") and "/v1/orgs/" not in path: + scoped_path = path.replace("/v1/", f"/v1/orgs/{org_id}/projects/{project_id}/", 1) + scoped_url = request.url.copy_with(path=scoped_path) + return httpx.Request( + method=request.method, + url=scoped_url, + headers=request.headers, + content=request.content, + extensions=request.extensions, + ) + return request + + +class OrgScopedTransport(httpx.BaseTransport): + """Sync transport that rewrites requests to include org/project scope.""" + + def __init__( + self, + wrapped_transport: httpx.BaseTransport, + org_id: str, + project_id: str, + ) -> None: + self.wrapped = wrapped_transport + self.org_id = org_id + self.project_id = project_id + + def handle_request(self, request: httpx.Request) -> httpx.Response: + scoped_request = _rewrite_request_path(request, self.org_id, self.project_id) + return self.wrapped.handle_request(scoped_request) + + def close(self) -> None: + self.wrapped.close() + + +class AsyncOrgScopedTransport(httpx.AsyncBaseTransport): + """Async transport that rewrites requests to include org/project scope.""" + + def __init__( + self, + wrapped_transport: httpx.AsyncBaseTransport, + org_id: str, + project_id: str, + ) -> None: + self.wrapped = wrapped_transport + self.org_id = org_id + self.project_id = project_id + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + scoped_request = _rewrite_request_path(request, self.org_id, self.project_id) + return await self.wrapped.handle_async_request(scoped_request) + + async def aclose(self) -> None: + await self.wrapped.aclose() + + +def build_org_scoped_http_client( + org_id: str, + project_id: str, + *, + base_transport: httpx.BaseTransport | None = None, + client_kwargs: dict | None = None, +) -> httpx.Client: + """ + Build a sync httpx.Client that rewrites /v1 requests with org/project scope. + """ + client_kwargs = client_kwargs or {} + transport = OrgScopedTransport( + base_transport or httpx.HTTPTransport(), org_id=org_id, project_id=project_id + ) + return httpx.Client(transport=transport, **client_kwargs) + + +def build_org_scoped_async_http_client( + org_id: str, + project_id: str, + *, + base_transport: httpx.AsyncBaseTransport | None = None, + client_kwargs: dict | None = None, +) -> httpx.AsyncClient: + """ + Build an async httpx.AsyncClient that rewrites /v1 requests with org/project scope. + """ + client_kwargs = client_kwargs or {} + transport = AsyncOrgScopedTransport( + base_transport or httpx.AsyncHTTPTransport(), org_id=org_id, project_id=project_id + ) + return httpx.AsyncClient(transport=transport, **client_kwargs) diff --git a/libs/arcade-core/arcade_core/usage/identity.py b/libs/arcade-core/arcade_core/usage/identity.py index 6d75bfe2..4418f1ca 100644 --- a/libs/arcade-core/arcade_core/usage/identity.py +++ b/libs/arcade-core/arcade_core/usage/identity.py @@ -124,34 +124,68 @@ class UsageIdentity: return str(data[KEY_ANON_ID]) def get_principal_id(self) -> str | None: - """Fetch principal_id from Arcade Cloud API. + """Fetch principal_id (account_id) from Arcade Cloud API. + + Prefers any linked principal already stored in usage.json. If not + found, attempts to fetch via OAuth access token and falls + back to legacy API key validation if present. Returns: str | None: Principal ID if authenticated and API call succeeds, None otherwise """ + # Prefer already-linked principal_id in usage.json + data = self.load_or_create() + linked_principal_id = data.get(KEY_LINKED_PRINCIPAL_ID) + if linked_principal_id: + return str(linked_principal_id) + if not os.path.exists(CREDENTIALS_FILE_PATH): return None try: with open(CREDENTIALS_FILE_PATH) as f: - config = yaml.safe_load(f) + config = yaml.safe_load(f) or {} - cloud_config = config.get("cloud", {}) - api_key = cloud_config.get("api", {}).get("key") + cloud_config = config.get("cloud", {}) if isinstance(config, dict) else {} - if not api_key: - return None + # Determine coordinator/authority URL for auth calls + coordinator_url = cloud_config.get("coordinator_url") or "https://cloud.arcade.dev" + whoami_url = f"{coordinator_url}/api/v1/auth/whoami" + validate_url = f"{coordinator_url}/api/v1/auth/validate" - response = httpx.get( - "https://cloud.arcade.dev/api/v1/auth/validate", - headers={"accept": "application/json", "Authorization": f"Bearer {api_key}"}, - timeout=TIMEOUT_ARCADE_API, + # OAuth credentials: use access_token to call /whoami + auth_config = cloud_config.get("auth", {}) if isinstance(cloud_config, dict) else {} + access_token = auth_config.get("access_token") + if access_token: + response = httpx.get( + whoami_url, + headers={ + "accept": "application/json", + "Authorization": f"Bearer {access_token}", + }, + timeout=TIMEOUT_ARCADE_API, + ) + if response.status_code == 200: + data = response.json().get("data", {}) + principal_id = data.get("account_id") or data.get("principal_id") + if principal_id: + return str(principal_id) + + # Legacy API key credentials (deprecated) + api_key = ( + cloud_config.get("api", {}).get("key") if isinstance(cloud_config, dict) else None ) - - if response.status_code == 200: - data = response.json() - principal_id = data.get("data", {}).get("principal_id") - return str(principal_id) if principal_id else None + if api_key: + response = httpx.get( + validate_url, + headers={"accept": "application/json", "Authorization": f"Bearer {api_key}"}, + timeout=TIMEOUT_ARCADE_API, + ) + if response.status_code == 200: + data = response.json() + principal_id = data.get("data", {}).get("principal_id") + if principal_id: + return str(principal_id) except Exception: # noqa: S110 # Silent failure - don't disrupt CLI diff --git a/libs/arcade-core/pyproject.toml b/libs/arcade-core/pyproject.toml index 7f5b8293..7a8c5f2b 100644 --- a/libs/arcade-core/pyproject.toml +++ b/libs/arcade-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "arcade-core" -version = "3.4.0" +version = "4.0.0" description = "Arcade Core - Core library for Arcade platform" readme = "README.md" license = {text = "MIT"} @@ -24,6 +24,7 @@ dependencies = [ "loguru>=0.7.0", "pyjwt>=2.8.0", "toml>=0.10.2", + "httpx>=0.27.0", "packaging>=24.1", "types-python-dateutil==2.9.0.20241003", "types-pytz==2024.2.0.20241003", diff --git a/libs/arcade-mcp-server/arcade_mcp_server/server.py b/libs/arcade-mcp-server/arcade_mcp_server/server.py index e7342133..6ce1ccdd 100644 --- a/libs/arcade-mcp-server/arcade_mcp_server/server.py +++ b/libs/arcade-mcp-server/arcade_mcp_server/server.py @@ -19,8 +19,10 @@ import logging import os from typing import Any, Callable, cast +from arcade_core.auth_tokens import get_valid_access_token from arcade_core.catalog import MaterializedTool, ToolCatalog from arcade_core.executor import ToolExecutor +from arcade_core.network.org_transport import build_org_scoped_async_http_client from arcade_core.schema import ToolAuthorizationContext, ToolContext from arcade_core.schema import ToolAuthRequirement as CoreToolAuthRequirement from arcadepy import ArcadeError, AsyncArcade @@ -216,31 +218,49 @@ class MCPServer: self._handlers = self._register_handlers() def _load_config_values(self) -> tuple[str | None, str | None]: - """Load API key and user_id from credentials file. + """Load access token and user_id from credentials file. Returns: - Tuple of (api_key, user_id) from credentials file, or (None, None) if not available + Tuple of (access_token, user_id) from credentials file, or (None, None) if not available """ try: from arcade_core.config import config - api_key = config.api.key if config.api else None + access_token = get_valid_access_token() user_id = config.user.email if config.user else None - if api_key or user_id: + if access_token or user_id: config_path = config.get_config_file_path() - if api_key: - logger.info(f"Loaded Arcade API key from {config_path}") + if access_token: + logger.info(f"Loaded Arcade access token from {config_path}") if user_id: logger.debug(f"Loaded user_id '{user_id}' from {config_path}") - return api_key, user_id + return access_token, user_id else: - logger.debug("No API key or user_id found in credentials file") + logger.debug( + "No access token or user_id found in credentials file. If this is unexpected, run 'arcade login' to authenticate." + ) return None, None except Exception as e: logger.debug(f"Could not load values from credentials file: {e}") return None, None + def _load_org_project_context(self) -> tuple[str, str] | None: + """ + Load org/project context from the shared Arcade config (same source as the CLI). + Returns (org_id, project_id) when both are available; otherwise None. + """ + try: + from arcade_core.config import config + + context = getattr(config, "context", None) + if context and context.org_id and context.project_id: + return context.org_id, context.project_id + logger.debug("Org/project context not found in arcade_core.config") + except Exception as e: + logger.debug(f"Could not load org/project context from config: {e}") + return None + def _init_arcade_client(self, api_key: str | None, api_url: str | None) -> None: """Initialize Arcade client for runtime authorization.""" self.arcade: AsyncArcade | None = None @@ -257,10 +277,31 @@ class MCPServer: if final_api_key: logger.info(f"Using Arcade client with API URL: {api_url}") - self.arcade = AsyncArcade(api_key=final_api_key, base_url=api_url) + client_kwargs: dict[str, Any] = {"api_key": final_api_key, "base_url": api_url} + + # Non-service keys need org/project URL rewriting + if not final_api_key.startswith("arc_"): + context = self._load_org_project_context() + if context: + org_id, project_id = context + client_kwargs["http_client"] = build_org_scoped_async_http_client( + org_id, project_id + ) + logger.info( + "Configured org-scoped Arcade client for org '%s' project '%s'", + org_id, + project_id, + ) + else: + logger.warning( + "Expected to find org/project context in arcade_core.config but no org/project context " + "was found; using non-scoped Arcade client." + ) + + self.arcade = AsyncArcade(**client_kwargs) else: logger.warning( - "Arcade API key not configured. Tools requiring auth will return a login instruction." + "Arcade access token not configured. Tools requiring auth will return a login instruction." ) def _init_middleware(self, custom_middleware: list[Middleware] | None) -> None: diff --git a/libs/arcade-mcp-server/pyproject.toml b/libs/arcade-mcp-server/pyproject.toml index 531eed34..1537bd0f 100644 --- a/libs/arcade-mcp-server/pyproject.toml +++ b/libs/arcade-mcp-server/pyproject.toml @@ -21,9 +21,9 @@ classifiers = [ ] requires-python = ">=3.10" dependencies = [ - "arcade-core>=3.4.0,<4.0.0", - "arcade-serve>=3.1.5,<4.0.0", - "arcade-tdk>=3.2.2,<4.0.0", + "arcade-core>=4.0.0,<5.0.0", + "arcade-serve>=3.2.0,<4.0.0", + "arcade-tdk>=3.3.0,<4.0.0", "arcadepy>=1.5.0", "pydantic>=2.0.0", "fastapi>=0.100.0", diff --git a/libs/arcade-serve/pyproject.toml b/libs/arcade-serve/pyproject.toml index 41fffb88..7092f2dc 100644 --- a/libs/arcade-serve/pyproject.toml +++ b/libs/arcade-serve/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "arcade-serve" -version = "3.1.5" +version = "3.2.0" description = "Arcade Serve - Serving infrastructure for Arcade tools and workers" readme = "README.md" license = {text = "MIT"} @@ -19,7 +19,7 @@ classifiers = [ ] requires-python = ">=3.10" dependencies = [ - "arcade-core>=3.4.0,<4.0.0", + "arcade-core>=4.0.0,<5.0.0", "fastapi>=0.115.3", "uvicorn>=0.30.0", "watchfiles>=1.0.5", diff --git a/libs/arcade-tdk/pyproject.toml b/libs/arcade-tdk/pyproject.toml index f85730b3..e8a8e6d2 100644 --- a/libs/arcade-tdk/pyproject.toml +++ b/libs/arcade-tdk/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "arcade-tdk" -version = "3.2.2" +version = "3.3.0" description = "Arcade TDK - Toolkit Development Kit for building Arcade tools" readme = "README.md" license = {text = "MIT"} @@ -19,7 +19,7 @@ classifiers = [ ] requires-python = ">=3.10" dependencies = [ - "arcade-core>=3.4.0,<4.0.0", + "arcade-core>=4.0.0,<5.0.0", "pydantic>=2.7.0", ] diff --git a/libs/tests/cli/test_dashboard.py b/libs/tests/cli/test_dashboard.py index 20298009..fcb1cd58 100644 --- a/libs/tests/cli/test_dashboard.py +++ b/libs/tests/cli/test_dashboard.py @@ -1,7 +1,7 @@ from unittest.mock import MagicMock, patch import pytest -from arcade_cli.constants import PROD_ENGINE_HOST +from arcade_core.constants import PROD_ENGINE_HOST from arcade_cli.main import cli from typer.testing import CliRunner @@ -24,7 +24,7 @@ def test_dashboard_url_construction(args, expected_url): """Test that the dashboard command constructs the correct URL with various args.""" with ( patch("webbrowser.open") as mock_open, - patch("arcade_cli.main.validate_and_get_config") as mock_validate, + patch("arcade_cli.utils.validate_and_get_config") as mock_validate, patch("arcade_cli.main.log_engine_health") as mock_health_check, ): # Setup mocks @@ -44,7 +44,7 @@ def test_fallback_when_browser_fails(): """Test fallback message when browser.open fails.""" with ( patch("webbrowser.open") as mock_open, - patch("arcade_cli.main.validate_and_get_config") as mock_validate, + patch("arcade_cli.utils.validate_and_get_config") as mock_validate, patch("arcade_cli.main.log_engine_health") as mock_health_check, patch("arcade_cli.main.console.print") as mock_print, ): @@ -65,7 +65,7 @@ def test_health_check_success(): """Test successful health check.""" with ( patch("webbrowser.open") as mock_open, - patch("arcade_cli.main.validate_and_get_config") as mock_validate, + patch("arcade_cli.utils.validate_and_get_config") as mock_validate, patch("arcade_cli.main.log_engine_health") as mock_health_check, ): mock_open.return_value = True diff --git a/libs/tests/cli/test_secret.py b/libs/tests/cli/test_secret.py index 935b2510..3ce78167 100644 --- a/libs/tests/cli/test_secret.py +++ b/libs/tests/cli/test_secret.py @@ -5,10 +5,10 @@ from unittest.mock import MagicMock, patch import httpx import pytest from arcade_cli.secret import ( - _delete_secret_from_engine, - _get_secrets_from_engine, + _delete_secret, + _get_secrets, _remove_inline_comment, - _upsert_secret_to_engine, + _upsert_secret, load_env_file, print_secret_table, ) @@ -144,46 +144,52 @@ class TestRemoveInlineComment: class TestUpsertSecretToEngine: - """Tests for _upsert_secret_to_engine function.""" + """Tests for _upsert_secret function.""" + @patch("arcade_cli.secret.get_auth_headers") + @patch("arcade_cli.secret.get_org_scoped_url") @patch("arcade_cli.secret.httpx.put") - def test_upsert_secret_success(self, mock_put): + def test_upsert_secret_success(self, mock_put, mock_get_url, mock_get_headers): """Test successful secret upsert.""" mock_response = MagicMock() mock_response.raise_for_status.return_value = None mock_put.return_value = mock_response + mock_get_url.return_value = "https://api.example.com/v1/org/test-org/secrets/SECRET_KEY" + mock_get_headers.return_value = {"Authorization": "Bearer test-api-key"} - _upsert_secret_to_engine( - "https://api.example.com", "test-api-key", "SECRET_KEY", "secret-value" - ) + _upsert_secret("SECRET_KEY", "secret-value") mock_put.assert_called_once_with( - "https://api.example.com/v1/admin/secrets/SECRET_KEY", + "https://api.example.com/v1/org/test-org/secrets/SECRET_KEY", headers={"Authorization": "Bearer test-api-key"}, json={"description": "Secret set via CLI", "value": "secret-value"}, ) mock_response.raise_for_status.assert_called_once() + @patch("arcade_cli.secret.get_auth_headers") + @patch("arcade_cli.secret.get_org_scoped_url") @patch("arcade_cli.secret.httpx.put") - def test_upsert_secret_http_error(self, mock_put): + def test_upsert_secret_http_error(self, mock_put, mock_get_url, mock_get_headers): """Test secret upsert with HTTP error.""" mock_response = MagicMock() mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( "Bad Request", request=MagicMock(), response=MagicMock() ) mock_put.return_value = mock_response + mock_get_url.return_value = "https://api.example.com/v1/org/test-org/secrets/SECRET_KEY" + mock_get_headers.return_value = {"Authorization": "Bearer test-api-key"} with pytest.raises(httpx.HTTPStatusError): - _upsert_secret_to_engine( - "https://api.example.com", "test-api-key", "SECRET_KEY", "secret-value" - ) + _upsert_secret("SECRET_KEY", "secret-value") class TestGetSecretsFromEngine: - """Tests for _get_secrets_from_engine function.""" + """Tests for _get_secrets function.""" + @patch("arcade_cli.secret.get_auth_headers") + @patch("arcade_cli.secret.get_org_scoped_url") @patch("arcade_cli.secret.httpx.get") - def test_get_secrets_success(self, mock_get): + def test_get_secrets_success(self, mock_get, mock_get_url, mock_get_headers): """Test successful secrets retrieval.""" mock_response = MagicMock() mock_response.raise_for_status.return_value = None @@ -194,58 +200,72 @@ class TestGetSecretsFromEngine: ] } mock_get.return_value = mock_response + mock_get_url.return_value = "https://api.example.com/v1/org/test-org/secrets" + mock_get_headers.return_value = {"Authorization": "Bearer test-api-key"} - secrets = _get_secrets_from_engine("https://api.example.com", "test-api-key") + secrets = _get_secrets() assert secrets == [ {"key": "SECRET1", "id": "id1"}, {"key": "SECRET2", "id": "id2"}, ] mock_get.assert_called_once_with( - "https://api.example.com/v1/admin/secrets", + "https://api.example.com/v1/org/test-org/secrets", headers={"Authorization": "Bearer test-api-key"}, ) mock_response.raise_for_status.assert_called_once() + @patch("arcade_cli.secret.get_auth_headers") + @patch("arcade_cli.secret.get_org_scoped_url") @patch("arcade_cli.secret.httpx.get") - def test_get_secrets_http_error(self, mock_get): + def test_get_secrets_http_error(self, mock_get, mock_get_url, mock_get_headers): """Test secrets retrieval with HTTP error.""" mock_response = MagicMock() mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( "Unauthorized", request=MagicMock(), response=MagicMock() ) mock_get.return_value = mock_response + mock_get_url.return_value = "https://api.example.com/v1/org/test-org/secrets" + mock_get_headers.return_value = {"Authorization": "Bearer test-api-key"} with pytest.raises(httpx.HTTPStatusError): - _get_secrets_from_engine("https://api.example.com", "test-api-key") + _get_secrets() class TestDeleteSecretFromEngine: - """Tests for _delete_secret_from_engine function.""" + """Tests for _delete_secret function.""" + @patch("arcade_cli.secret.get_auth_headers") + @patch("arcade_cli.secret.get_org_scoped_url") @patch("arcade_cli.secret.httpx.delete") - def test_delete_secret_success(self, mock_delete): + def test_delete_secret_success(self, mock_delete, mock_get_url, mock_get_headers): """Test successful secret deletion.""" mock_response = MagicMock() mock_response.raise_for_status.return_value = None mock_delete.return_value = mock_response + mock_get_url.return_value = "https://api.example.com/v1/org/test-org/secrets/secret-id-123" + mock_get_headers.return_value = {"Authorization": "Bearer test-api-key"} - _delete_secret_from_engine("https://api.example.com", "test-api-key", "secret-id-123") + _delete_secret("secret-id-123") mock_delete.assert_called_once_with( - "https://api.example.com/v1/admin/secrets/secret-id-123", + "https://api.example.com/v1/org/test-org/secrets/secret-id-123", headers={"Authorization": "Bearer test-api-key"}, ) mock_response.raise_for_status.assert_called_once() + @patch("arcade_cli.secret.get_auth_headers") + @patch("arcade_cli.secret.get_org_scoped_url") @patch("arcade_cli.secret.httpx.delete") - def test_delete_secret_http_error(self, mock_delete): + def test_delete_secret_http_error(self, mock_delete, mock_get_url, mock_get_headers): """Test secret deletion with HTTP error.""" mock_response = MagicMock() mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( "Not Found", request=MagicMock(), response=MagicMock() ) mock_delete.return_value = mock_response + mock_get_url.return_value = "https://api.example.com/v1/org/test-org/secrets/secret-id-123" + mock_get_headers.return_value = {"Authorization": "Bearer test-api-key"} with pytest.raises(httpx.HTTPStatusError): - _delete_secret_from_engine("https://api.example.com", "test-api-key", "secret-id-123") + _delete_secret("secret-id-123") diff --git a/libs/tests/cli/test_utils.py b/libs/tests/cli/test_utils.py index e67d8ad7..a51bc53b 100644 --- a/libs/tests/cli/test_utils.py +++ b/libs/tests/cli/test_utils.py @@ -1,5 +1,5 @@ import pytest -from arcade_cli.utils import Provider, compute_base_url, compute_login_url, resolve_provider_api_key +from arcade_cli.utils import Provider, compute_base_url, resolve_provider_api_key DEFAULT_CLOUD_HOST = "cloud.arcade.dev" DEFAULT_ENGINE_HOST = "api.arcade.dev" @@ -195,50 +195,6 @@ def test_compute_base_url(inputs: dict, expected_output: str): assert base_url == expected_output -@pytest.mark.parametrize( - "inputs, expected_output", - [ - pytest.param( - {"host_input": DEFAULT_CLOUD_HOST, "port_input": DEFAULT_PORT, "state": "123"}, - "https://cloud.arcade.dev/api/v1/auth/cli_login?callback_uri=http%3A%2F%2Flocalhost%3A9905%2Fcallback&state=123", - id="default", - ), - pytest.param( - {"host_input": "localhost", "port_input": 9099, "state": "123"}, - "http://localhost:9099/api/v1/auth/cli_login?callback_uri=http%3A%2F%2Flocalhost%3A9905%2Fcallback&state=123", - id="localhost with custom port", - ), - pytest.param( - {"host_input": "localhost", "port_input": DEFAULT_PORT, "state": "123"}, - "http://localhost:8000/api/v1/auth/cli_login?callback_uri=http%3A%2F%2Flocalhost%3A9905%2Fcallback&state=123", - id="localhost", - ), - pytest.param( - {"host_input": DEFAULT_CLOUD_HOST, "port_input": 8000, "state": "123"}, - "https://cloud.arcade.dev/api/v1/auth/cli_login?callback_uri=http%3A%2F%2Flocalhost%3A9905%2Fcallback&state=123", - id="cloud host with an ignored custom port", - ), - pytest.param( - { - "host_input": DEFAULT_CLOUD_HOST, - "port_input": DEFAULT_PORT, - "state": "123", - "callback_host": "other-host.com/123", - }, - "https://cloud.arcade.dev/api/v1/auth/cli_login?callback_uri=http%3A%2F%2Fother-host.com%2F123%2Fcallback&state=123", - id="cloud host with a custom callback host", - ), - ], -) -def test_compute_login_url(inputs: dict, expected_output: str): - callback_host = inputs.get("callback_host") - login_url = compute_login_url( - inputs["host_input"], inputs["state"], inputs["port_input"], callback_host - ) - - assert login_url == expected_output - - def test_resolve_provider_api_key(monkeypatch): resolved_api_key = resolve_provider_api_key(Provider.OPENAI, "123") assert resolved_api_key == "123" diff --git a/libs/tests/cli/usage/test_identity.py b/libs/tests/cli/usage/test_identity.py index 91f4108f..1dd6e64e 100644 --- a/libs/tests/cli/usage/test_identity.py +++ b/libs/tests/cli/usage/test_identity.py @@ -253,6 +253,30 @@ class TestGetPrincipalId: assert principal_id is None + @patch("httpx.get") + def test_returns_account_id_from_oauth_whoami( + self, mock_get: MagicMock, identity: UsageIdentity, temp_config_path: Path + ) -> None: + """Test that get_principal_id returns account_id using OAuth access token.""" + credentials_file = temp_config_path / "credentials.yaml" + credentials_file.write_text( + yaml.dump({"cloud": {"auth": {"access_token": "oauth-token", "refresh_token": "x"}}}) + ) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"data": {"account_id": "acct-123"}} + mock_get.return_value = mock_response + + principal_id = identity.get_principal_id() + + assert principal_id == "acct-123" + mock_get.assert_called_once_with( + "https://cloud.arcade.dev/api/v1/auth/whoami", + headers={"accept": "application/json", "Authorization": "Bearer oauth-token"}, + timeout=2.0, + ) + class TestShouldAlias: """Tests for should_alias() method.""" diff --git a/pyproject.toml b/pyproject.toml index 497fb1ce..de00c1b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "arcade-mcp" -version = "1.5.9" +version = "1.6.0" description = "Arcade.dev - Tool Calling platform for Agents" readme = "README.md" license = {file = "LICENSE"} @@ -21,11 +21,12 @@ requires-python = ">=3.10" dependencies = [ # CLI dependencies - "arcade-mcp-server>=1.11.1,<2.0.0", - "arcade-core>=3.4.0,<4.0.0", + "arcade-mcp-server>=1.12.0,<2.0.0", + "arcade-core>=4.0.0,<5.0.0", "typer==0.10.0", "rich==13.9.4", "Jinja2==3.1.6", + "authlib==1.3.0", "arcadepy==1.8.0", "tqdm==4.67.1", "openai==1.82.1", @@ -42,11 +43,11 @@ all = [ "pytz>=2024.1", "python-dateutil>=2.8.2", # mcp - "arcade-mcp-server>=1.11.1,<2.0.0", + "arcade-mcp-server>=1.12.0,<2.0.0", # serve - "arcade-serve>=3.1.5,<4.0.0", + "arcade-serve>=3.2.0,<4.0.0", # tdk - "arcade-tdk>=3.2.2,<4.0.0", + "arcade-tdk>=3.3.0,<4.0.0", ] # Evals also depends on arcade-core and openai, but they are already required deps evals = [ @@ -65,6 +66,7 @@ dev-dependencies = [ "mypy>=1.5.1", "pre-commit>=3.4.0", "ruff>=0.4.0", + "types-Authlib>=1.3.0", "types-PyYAML>=6.0.0", "types-python-dateutil>=2.8.2", "types-pytz>=2024.1", @@ -92,6 +94,10 @@ packages = [ "libs/arcade-evals/arcade_evals", ] +# Include Jinja templates in the CLI package +[tool.hatch.build.targets.wheel.force-include] +"libs/arcade-cli/arcade_cli/templates" = "arcade_cli/templates" + [tool.uv.workspace] members = [ "libs/arcade-core",