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.' }}
+
+
+
+ {% if error or error_description %}
+
+
+
+
+
+ {% if error %}
+
+ {% endif %}
+ {% if error_description %}
+
+
Description
+
{{ error_description }}
+
+ {% endif %}
+ {% if 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.
+
+
+
+{% 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",