diff --git a/arcade/arcade/cli/authn.py b/arcade/arcade/cli/authn.py index 90ea5fa5..d2615b1b 100644 --- a/arcade/arcade/cli/authn.py +++ b/arcade/arcade/cli/authn.py @@ -7,8 +7,12 @@ from urllib.parse import parse_qs import yaml from rich.console import Console -from arcade.cli.constants import LOGIN_FAILED_HTML, LOGIN_SUCCESS_HTML -from arcade.cli.utils import create_new_env_file +from arcade.cli.constants import ( + ARCADE_CONFIG_PATH, + CREDENTIALS_FILE_PATH, + LOGIN_FAILED_HTML, + LOGIN_SUCCESS_HTML, +) console = Console() @@ -56,16 +60,13 @@ class LoginCallbackHandler(BaseHTTPRequestHandler): ) return False - # ensure the ~/.arcade directory exists - # TODO: this should use WORK_DIR from env if set - arcade_dir = os.path.join(os.path.expanduser("~"), ".arcade") - if not os.path.exists(arcade_dir): - os.makedirs(arcade_dir, exist_ok=True) + # 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 - config_file_path = os.path.join(arcade_dir, "credentials.yaml") new_config = {"cloud": {"api": {"key": api_key}, "user": {"email": email}}} - with open(config_file_path, "w") as f: + with open(CREDENTIALS_FILE_PATH, "w") as f: yaml.dump(new_config, f) # Send a success response to the browser @@ -73,7 +74,7 @@ class LoginCallbackHandler(BaseHTTPRequestHandler): f"""✅ Hi there, {email}! Your Arcade API key is: {api_key} -Stored in: {config_file_path}""", +Stored in: {CREDENTIALS_FILE_PATH}""", style="bold green", ) return True @@ -112,35 +113,34 @@ class LocalAuthCallbackServer: self.httpd.shutdown() -def check_existing_login() -> bool: +def check_existing_login(suppress_message: bool = False) -> bool: """ Check if the user is already logged in by verifying the config file. + Args: + suppress_message (bool): If True, suppress the logged in message. + Returns: bool: True if the user is already logged in, False otherwise. """ - # Create a new env file if one doesn't already exist - create_new_env_file() - - config_file_path = os.path.expanduser("~/.arcade/credentials.yaml") - - if not os.path.exists(config_file_path): + if not os.path.exists(CREDENTIALS_FILE_PATH): return False - if os.path.exists(config_file_path): + if os.path.exists(CREDENTIALS_FILE_PATH): try: - with open(config_file_path) as f: + 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") if api_key and email: - console.print(f"You're already logged in as {email}. ", style="bold green") + 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 {config_file_path}", style="bold red" + 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") diff --git a/arcade/arcade/cli/constants.py b/arcade/arcade/cli/constants.py index 811cf8d8..56783609 100644 --- a/arcade/arcade/cli/constants.py +++ b/arcade/arcade/cli/constants.py @@ -1,7 +1,15 @@ -DEFAULT_CLOUD_HOST = "cloud.arcade.dev" -DEFAULT_ENGINE_HOST = "api.arcade.dev" +import os + +PROD_CLOUD_HOST = "cloud.arcade.dev" +PROD_ENGINE_HOST = "api.arcade.dev" LOCALHOST = "localhost" +# The path to the directory containing the Arcade configuration files. Typically ~/.arcade +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") + _style_block = b""" diff --git a/arcade/arcade/cli/launcher.py b/arcade/arcade/cli/launcher.py index 74d5d827..bae9f952 100644 --- a/arcade/arcade/cli/launcher.py +++ b/arcade/arcade/cli/launcher.py @@ -26,7 +26,8 @@ known_engine_config_locations = [ if os.environ.get("HOMEBREW_REPOSITORY") is not None: homebrew_home = os.path.join(os.environ["HOMEBREW_REPOSITORY"], "etc", "arcade-engine") - known_engine_config_locations.append(homebrew_home) + if homebrew_home not in known_engine_config_locations: + known_engine_config_locations.append(homebrew_home) def start_servers( @@ -54,7 +55,7 @@ def start_servers( engine_config = _get_config_file(engine_config, default_filename="engine.yaml") # Ensure engine_env is provided or found and either way, validated - env_file = _get_config_file(engine_env, default_filename="arcade.env", optional=True) + env_file = _get_config_file(engine_env, default_filename="engine.env", optional=True) # Prepare command-line arguments for the worker server and engine worker_cmd = _build_worker_command(worker_host, worker_port, debug) @@ -113,7 +114,13 @@ def _get_config_file( file_path: str | None, default_filename: str = "engine.yaml", optional: bool = False ) -> str | None: """ - Determines and validates the config file path. + Resolves and validates the config file path from a set of candidate locations. + + If a file_path is provided, it is checked directly. + Otherwise, the following candidate locations are checked in order: + 1. Current working directory. + 2. User's home directory under .arcade. + 3. Known engine config locations. Args: file_path: Optional path provided by the user. @@ -127,52 +134,47 @@ def _get_config_file( RuntimeError: If the config file is not found and is not optional. """ if file_path: - config_path = Path(os.path.expanduser(file_path)).resolve() - if not config_path.is_file(): - console.print(f"❌ Config file not found at {config_path}", style="bold red") - raise RuntimeError(f"Config file not found at {config_path}") - return str(config_path) + candidate = Path(os.path.expanduser(file_path)).resolve() + if not candidate.is_file(): + console.print(f"❌ Config file not found at {candidate}", style="bold red") + raise RuntimeError(f"Config file not found at {candidate}") + return str(candidate) - # Look for the file in the current working directory - config_path = Path(os.getcwd()) / default_filename - if config_path.is_file(): - console.print(f"Using config file at {config_path}", style="bold green") - return str(config_path) + # List of all config file path locations to check. + candidates = [ + Path(os.getcwd()) / default_filename, + Path.home() / ".arcade" / default_filename, + ] + candidates.extend(Path(path) / default_filename for path in known_engine_config_locations) - # Look for the file in the user's home directory under .arcade/ - home_config_path = Path.home() / ".arcade" / default_filename - if home_config_path.is_file(): - console.print(f"Using config file at {home_config_path}", style="bold green") - return str(home_config_path) - - # Look for known installation directories - for path in known_engine_config_locations: - etc_path = Path(path) / default_filename - if etc_path.is_file(): - console.print(f"Using config file at {etc_path}", style="bold green") - return str(etc_path) + # Find the first candidate that exists. + for candidate in candidates: + if candidate.is_file(): + console.print(f"Using config file at {candidate}", style="bold green") + return str(candidate) + # No config file was found. Handle according to the optional flag. if optional: console.print( - f"⚠️ Optional config file '{default_filename}' not found in either of the default locations: " - f" 1) Current working directory: {Path.cwd() / default_filename}, or " - f" 2) User's home directory: {Path.home() / '.arcade' / default_filename}.", + f"⚠️ Optional config file '{default_filename}' not found in any of the following locations:", style="bold yellow", ) + for i, candidate in enumerate(candidates, start=1): + console.print(f" {i}) {candidate}", style="bold yellow") return None console.print( - f"❌ Error: Required config file '{default_filename}' not found in any of the default locations:\n" - f" 1) Current working directory: {Path.cwd() / default_filename}, or\n" - f" 2) User's home directory: {Path.home() / '.arcade' / default_filename}\n", + f"❌ Error: Required config file '{default_filename}' not found in any of the following locations:", style="bold red", ) + for i, candidate in enumerate(candidates, start=1): + console.print(f" {i}) {candidate}", style="bold red") + console.print( - "TIP: Please install the Arcade Engine by following the instructions at:\n" + "\nTIP: Please install the Arcade Engine by following the instructions at:\n" " https://docs.arcade.dev/home/install/local#install-the-engine\n", style="bold green", ) - raise RuntimeError(f"Config file '{default_filename}' not found.") diff --git a/arcade/arcade/cli/main.py b/arcade/arcade/cli/main.py index 3a0ce8be..680eafe9 100644 --- a/arcade/arcade/cli/main.py +++ b/arcade/arcade/cli/main.py @@ -15,7 +15,12 @@ from rich.text import Text from tqdm import tqdm from arcade.cli.authn import LocalAuthCallbackServer, check_existing_login -from arcade.cli.constants import DEFAULT_CLOUD_HOST, DEFAULT_ENGINE_HOST, LOCALHOST +from arcade.cli.constants import ( + CREDENTIALS_FILE_PATH, + LOCALHOST, + PROD_CLOUD_HOST, + PROD_ENGINE_HOST, +) from arcade.cli.display import ( display_arcade_chat_header, display_eval_results, @@ -53,7 +58,7 @@ console = Console() @cli.command(help="Log in to Arcade Cloud", rich_help_panel="User") def login( host: str = typer.Option( - DEFAULT_CLOUD_HOST, + PROD_CLOUD_HOST, "-h", "--host", help="The Arcade Cloud host to log in to.", @@ -70,7 +75,9 @@ def login( """ if check_existing_login(): - console.print("Delete ~/.arcade/credentials.yaml to log in as a different user.\n") + 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 @@ -104,10 +111,9 @@ def logout() -> None: """ Logs the user out of Arcade Cloud. """ - # If ~/.arcade/credentials.yaml exists, delete it - config_file_path = os.path.expanduser("~/.arcade/credentials.yaml") - if os.path.exists(config_file_path): - os.remove(config_file_path) + # If the credentials file exists, delete it + if os.path.exists(CREDENTIALS_FILE_PATH): + os.remove(CREDENTIALS_FILE_PATH) console.print("You're now logged out.", style="bold") else: console.print("You're not logged in.", style="bold red") @@ -141,7 +147,7 @@ def show( None, "-t", "--tool", help="The specific tool to show details for" ), host: str = typer.Option( - DEFAULT_ENGINE_HOST, + PROD_ENGINE_HOST, "-h", "--host", help="The Arcade Engine address to show the tools/toolkits of.", @@ -185,7 +191,7 @@ def chat( prompt: str = typer.Option(None, "--prompt", help="The system prompt to use for the chat."), debug: bool = typer.Option(False, "--debug", "-d", help="Show debug information"), host: str = typer.Option( - DEFAULT_ENGINE_HOST, + PROD_ENGINE_HOST, "-h", "--host", help="The Arcade Engine address to send chat requests to.", @@ -345,7 +351,7 @@ def evals( """ config = validate_and_get_config() - host = DEFAULT_ENGINE_HOST if cloud else host + host = PROD_ENGINE_HOST if cloud else host base_url = compute_engine_base_url(force_tls, force_no_tls, host, port) models_list = models.split(",") # Use 'models_list' to avoid shadowing @@ -479,8 +485,9 @@ def workerup( @cli.callback() -def version( - _: bool = typer.Option( +def main_callback( + ctx: typer.Context, + _: Optional[bool] = typer.Option( None, "-v", "--version", @@ -489,4 +496,11 @@ def version( help="Print version and exit.", ), ) -> None: - pass + excluded_commands = {login.__name__, logout.__name__} + if ctx.invoked_subcommand in excluded_commands: + return + + if not check_existing_login(suppress_message=True): + console.print("Not logged in to Arcade CLI. Use ", style="bold red", end="") + console.print("arcade login", style="bold green") + raise typer.Exit() diff --git a/arcade/arcade/cli/utils.py b/arcade/arcade/cli/utils.py index 595600fb..388dad76 100644 --- a/arcade/arcade/cli/utils.py +++ b/arcade/arcade/cli/utils.py @@ -1,6 +1,5 @@ import importlib.util import ipaddress -import os import webbrowser from dataclasses import dataclass from enum import Enum @@ -560,24 +559,6 @@ def load_eval_suites(eval_files: list[Path]) -> list[Callable]: return eval_suites -def create_new_env_file() -> None: - """ - Create a new env file if one doesn't already exist. - """ - env_file = os.path.expanduser("~/.arcade/arcade.env") - if not os.path.exists(env_file): - template_path = os.path.join( - os.path.dirname(__file__), "..", "templates", "arcade.template.env" - ) - os.makedirs(os.path.dirname(env_file), exist_ok=True) - - with open(template_path) as template_file, open(env_file, "w") as new_env_file: - template_contents = template_file.read() - new_env_file.write(template_contents) - - console.print(f"Created new environment file at {env_file}", style="bold green") - - def get_user_input() -> str: """ Get input from the user, handling multi-line input. diff --git a/arcade/arcade/templates/arcade.template.env b/arcade/arcade/templates/arcade.template.env deleted file mode 100644 index 88b01f3e..00000000 --- a/arcade/arcade/templates/arcade.template.env +++ /dev/null @@ -1,50 +0,0 @@ -### Engine Configuration ### - -TELEMETRY_ENVIRONMENT=local -TELEMETRY_LOGGING_LEVEL=debug -TELEMETRY_LOGGING_ENCODING=console -API_DEVELOPMENT=true -ARCADE_API_HOST=localhost -ARCADE_API_PORT=9099 - - -### LLM API KEY ### - -# OPENAI_API_KEY= -# ANTHROPIC_API_KEY= -# GROQ_API_KEY= - -### Worker Configuration ### - -ARCADE_WORKER_URI=http://localhost:8002 -# ARCADE_WORKER_SECRET= - -### Token Storage ### -# REDIS_HOST= -# REDIS_PORT= - -### Integrations ### - -# GITHUB_CLIENT_ID= -# GITHUB_CLIENT_SECRET= - -# GOOGLE_CLIENT_ID= -# GOOGLE_CLIENT_SECRET= - -# LINKEDIN_CLIENT_ID= -# LINKEDIN_CLIENT_SECRET= - -# MICROSOFT_CLIENT_ID= -# MICROSOFT_CLIENT_SECRET= - -# SLACK_CLIENT_ID= -# SLACK_CLIENT_SECRET= - -# SPOTIFY_CLIENT_ID= -# SPOTIFY_CLIENT_SECRET= - -# X_CLIENT_ID= -# X_CLIENT_SECRET= - -# ZOOM_CLIENT_ID= -# ZOOM_CLIENT_SECRET=