feat: Support multiple orgs & projects in Arcade CLI (#717)

Fixes [PLT-720: Refactor CLI to support multiple orgs +
projects](https://linear.app/arcadedev/issue/PLT-720/refactor-cli-to-support-multiple-orgs-projects)

This PR removes the legacy login flow (login to get an API key) from
Arcade CLI. Believe it or not, this flow predates the ability to get an
API key from the Dashboard, or even the Dashboard itself!

Notable changes:

**Legacy handling** - When a user with an existing `credentials.yaml`
updates the CLI, they will get instructions on fixing their old
credentials:
<img width="978" height="146" alt="Screenshot 2025-12-08 at 10 10 37"
src="https://github.com/user-attachments/assets/5aeaef2c-bef7-4642-a2f7-f917b257c94b"
/>

Any commands that require login (non-public commands) will be blocked
with the above message until `arcade logout / arcade login` is performed
again.

**New login flow**

```sh
arcade login
Opening a browser to log you in...

 Logged in as nate@arcade.dev.

Active project: Nate Barbettini's organization / Default project
Run 'arcade org list' or 'arcade project list' to see available options.
```

**List and set the active organization**
```sh
arcade org list
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━┓
┃ Name                           ┃ ID                                   ┃ Default ┃ Active ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━┩
│ Nate Barbettini's organization │ 1c64968e-fdc5-4c55-8612-2ce46cd7881b │ ✓       │ ✓      │
│ Sergio 743                     │ 1f1f6184-58dc-4bac-bdde-b9184e43fdf3 │         │        │
└────────────────────────────────┴──────────────────────────────────────┴─────────┴────────┘

Use 'arcade org set <org_id>' to switch organizations.
```
```sh
arcade org set 1c64968e-fdc5-4c55-8612-2ce46cd7881b 

✓ Switched to organization: Nate Barbettini's organization
  Active project: Default project
```

**List and set the active project**
```sh
arcade project list

Active organization: Nate Barbettini's organization
Use 'arcade org list' and 'arcade org set <org_id>' to switch organizations.

┏━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━┓
┃ Name            ┃ ID                                   ┃ Default ┃ Active ┃
┡━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━┩
│ Default project │ 35166bf3-6e68-481e-bf16-f747fadc6c22 │ ✓       │ ✓      │
│ Second project  │ 62963205-31ea-4fda-9fc4-af10db89c06f │         │        │
└─────────────────┴──────────────────────────────────────┴─────────┴────────┘

Use 'arcade project set <project_id>' to switch projects.
```
```sh
arcade project set 35166bf3-6e68-481e-bf16-f747fadc6c22
✓ Switched to project: Default project
```

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Migrates CLI to OAuth2 (PKCE) with saved org/project context, adds
org/project commands, rewrites Engine calls to org-scoped endpoints, and
bumps core packages.
> 
> - **Auth & Config**
> - Implement OAuth2 Authorization Code + PKCE (`arcade_cli/authn.py`)
with local callback server and Jinja templates.
> - Persist tokens and active `context` (org/project) in
`credentials.yaml` via updated config models
(`arcade_core/config_model.py`).
> - Add token refresh and CLI config fetch utilities
(`arcade_core/auth_tokens.py`).
> - Detect legacy API-key credentials and block protected commands until
re-login; add `whoami` command.
> - **Org/Project Management**
> - New subcommands: `arcade org list|set`, `arcade project list|set`
(fetch via Coordinator).
> - **Engine API usage (org-scoped)**
> - Introduce org/project URL rewriting transports
(`arcade_core/network/org_transport.py`) and helpers
(`get_org_scoped_url`, `get_arcade_client`, `get_auth_headers`).
> - Update `deploy`, `server`, and `secret` commands to use Bearer
tokens and org-scoped paths; adjust log streaming/status, secrets CRUD,
and deployment workflows.
> - **CLI UX**
> - Replace legacy login URLs/constants; add success/failure HTML
templates for browser callback.
>   - Tweak `dashboard` to health-check without credentials.
>   - Usage tracking now includes `org_id`/`project_id` properties.
> - **Tests**
> - Update tests for dashboard, secrets, utils, and usage identity
(OAuth `/whoami`).
> - **Dependencies & Versions**
> - Bump packages: `arcade-core@4.0.0`, `arcade-mcp-server@1.12.0`,
`arcade-serve@3.2.0`, `arcade-tdk@3.3.0`; add `authlib`.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
49702c2f74b9db15bb286d3ec71179b4e74a9134. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
This commit is contained in:
Nate Barbettini 2025-12-11 12:58:55 -08:00 committed by GitHub
parent 98fd13c4ed
commit aae9b3a49c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 2136 additions and 565 deletions

View file

@ -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

View file

@ -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"""
<link rel="icon" href="https://cdn.arcade.dev/favicons/favicon.ico" sizes="any">
<link rel="apple-touch-icon" href="https://cdn.arcade.dev/favicons/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="https://cdn.arcade.dev/favicons/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="https://cdn.arcade.dev/favicons/favicon-16x16.png">
<link rel="apple-touch-icon" sizes="180x180" href="https://cdn.arcade.dev/favicons/apple-touch-icon.png">
<style>
body {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: linear-gradient(135deg, #1a1a1a, #0f0f0f);
font-family: Arial, sans-serif;
}
.container {
background-color: #333;
padding: 40px;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
width: 300px;
}
.container h2 {
color: #fff;
margin-bottom: 20px;
text-align: center;
}
.container label {
display: block;
color: #bbb;
margin-bottom: 5px;
font-size: 14px;
}
.container input[type="text"],
.container input[type="password"] {
width: 100%;
padding: 10px;
margin-bottom: 15px;
border: none;
border-radius: 4px;
background-color: #444;
color: #ddd;
font-size: 16px;
box-sizing: border-box;
}
.container input[type="text"]::placeholder,
.container input[type="password"]::placeholder {
color: #aaa;
}
.container input[type="submit"] {
width: 100%;
padding: 10px;
border: none;
border-radius: 4px;
background-color: #ED155D;
color: #fff;
font-size: 16px;
cursor: pointer;
transition: background-color 0.3s ease;
}
.container input[type="submit"]:hover {
background-color: #C0104A;
}
.message {
background-color: #1e1e1e;
padding: 10px;
border-radius: 4px;
margin-bottom: 15px;
font-size: 14px;
text-align: center;
}
.info {
color: #fff;
}
.error {
color: #ff4d4d;
}
.logo {
display: block;
max-width: 100%;
max-height: 90px;
margin: 0 auto 20px;
}
</style>
"""
LOGIN_SUCCESS_HTML = (
b"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Success!</title>
"""
+ _style_block
+ b"""
</head>
<body>
<div class="container">
<img src="https://cdn.arcade.dev/logos/a-icon.png" alt="Arcade logo" class="logo">
<h2>Log in to Arcade CLI</h2>
<p class="message info">Success! You can close this window.</p>
</div>
</body>
</html>
"""
)
LOGIN_FAILED_HTML = (
b"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login failed</title>
"""
+ _style_block
+ b"""
</head>
<body>
<div class="container">
<img src="https://cdn.arcade.dev/logos/a-icon.png" alt="Arcade logo" class="logo">
<h2>Log in to Arcade CLI</h2>
<p class="message error">Something went wrong. Please close this window and try again.</p>
</div>
</body>
</html>
"""
)
__all__ = [
"LOCALHOST",
"PROD_COORDINATOR_HOST",
"PROD_ENGINE_HOST",
]

View file

@ -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":

View file

@ -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.")

View file

@ -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 <org_id>' 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)

View file

@ -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 <org_id>' 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 <project_id>' 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)

View file

@ -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()

View file

@ -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(

View file

@ -0,0 +1,152 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}{% endblock %}</title>
<link rel="icon" type="image/svg+xml" href="https://cdn.arcade.dev/favicons/favicon.svg" />
<link
rel="icon"
type="image/png"
sizes="32x32"
href="https://cdn.arcade.dev/favicons/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="https://cdn.arcade.dev/favicons/favicon-16x16.png"
/>
<link
rel="apple-touch-icon"
sizes="180x180"
href="https://cdn.arcade.dev/favicons/apple-touch-icon.png"
/>
<style>
:root {
color-scheme: dark;
}
*, *::before, *::after {
box-sizing: border-box;
}
body {
min-height: 100vh;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: radial-gradient(circle at top, color-mix(in srgb, var(--primary, #ED155D) 20%, transparent), #050505 55%);
color: var(--text-primary, #f7f7f7);
display: flex;
justify-content: center;
align-items: center;
padding: clamp(24px, 5vw, 48px);
}
h1, h2, h3, h4, h5, h6 {
margin: 0;
line-height: 1.3;
}
h2 {
font-size: clamp(1.75rem, 4vw, 2rem);
font-weight: 600;
color: var(--text-primary, #ffffff);
}
p {
margin: 0;
line-height: 1.6;
}
.container {
width: min(560px, 100%);
}
.arc-card {
background: var(--surface-card, rgba(18, 18, 18, 0.92));
border: 1px solid color-mix(in srgb, var(--border-default, #2c2c2c) 65%, transparent);
border-radius: 24px;
box-shadow: var(--shadow-lg, 0 25px 60px rgba(0, 0, 0, 0.55));
padding: clamp(32px, 6vw, 48px);
display: flex;
flex-direction: column;
gap: 32px;
}
.arc-logo {
display: block;
width: 80px;
height: 80px;
margin: 0 auto;
}
.arc-heading {
margin: 0;
text-align: center;
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary, #ffffff);
}
a {
color: var(--primary, #ED155D);
text-decoration: none;
}
a:hover {
color: color-mix(in srgb, var(--primary, #ED155D), #ffffff 20%);
}
.branding {
border-top: 1px solid color-mix(in srgb, var(--border-default, #2a2a2a), transparent 30%);
padding-top: 24px;
text-align: center;
display: flex;
flex-direction: column;
gap: 8px;
}
.branding-text {
font-size: 0.875rem;
color: var(--text-muted, #bcbcbc);
}
.docs-link {
color: var(--primary, #ED155D);
text-decoration: none;
font-weight: 500;
transition: color 0.2s ease;
}
.docs-link:hover {
color: color-mix(in srgb, var(--primary, #ED155D), #ffffff 20%);
text-decoration: underline;
}
{% block styles %}{% endblock %}
</style>
{% block scripts %}{% endblock %}
</head>
<body>
<main class="container">
<section class="arc-card" aria-live="polite">
<img
src="https://cdn.arcade.dev/logos/circle_texture.png"
width="80"
height="80"
alt="Arcade.dev logo"
class="arc-logo"
/>
{% set heading_block %}{% block heading %}{% endblock %}{% endset %}
{% if heading_block | trim %}
<h2 class="arc-heading">{{ heading_block | trim | safe }}</h2>
{% endif %}
{% block content %}{% endblock %}
</section>
</main>
</body>
</html>

View file

@ -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 %}
<script>
function toggleDeveloperInfo() {
const details = document.getElementById('developerDetails');
const button = document.querySelector('.developer-toggle');
if (details.classList.contains('show')) {
details.classList.remove('show');
button.textContent = 'Show developer info';
} else {
details.classList.add('show');
button.textContent = 'Hide developer info';
}
}
</script>
{% endblock %}
{% block content %}
<div class="arc-stack">
<h2>Authorization failed</h2>
<p class="message-error">{{ message if message else 'Something went wrong. Please close this window and try again.' }}</p>
<div class="branding">
<p class="branding-text">Powered by <a href="https://arcade.dev" class="docs-link" target="_blank" rel="noopener">Arcade.dev</a></p>
</div>
{% if error or error_description %}
<div class="developer-section">
<button class="developer-toggle" onclick="toggleDeveloperInfo()">
Show developer info
</button>
<div class="developer-details" id="developerDetails">
<div class="developer-details-inner">
<div class="arc-panel">
{% if error %}
<div class="error-block">
<div class="error-label">Error</div>
<div class="error-value">{{ error }}</div>
</div>
{% endif %}
{% if error_description %}
<div class="error-block">
<div class="error-label">Description</div>
<div class="error-value">{{ error_description }}</div>
</div>
{% endif %}
{% if state %}
<div class="error-block">
<div class="error-label">State</div>
<div class="error-value">{{ state }}</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endif %}
</div>
{% endblock %}

View file

@ -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 %}
<div class="arc-stack">
<h2>Authorization successful.</h2>
<p>You can close this window and return to your terminal.</p>
<div class="branding">
<p class="branding-text">Powered by <a href="https://arcade.dev" class="docs-link" target="_blank" rel="noopener">Arcade.dev</a></p>
</div>
</div>
{% endblock %}

View file

@ -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

View file

@ -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"

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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"

View file

@ -0,0 +1 @@
# Network utilities shared across Arcade components.

View file

@ -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)

View file

@ -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

View file

@ -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",

View file

@ -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:

View file

@ -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",

View file

@ -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",

View file

@ -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",
]

View file

@ -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

View file

@ -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")

View file

@ -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"

View file

@ -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."""

View file

@ -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",