Improve Arcade CLI (#244)

This commit is contained in:
Eric Gustin 2025-02-08 10:56:04 -08:00 committed by GitHub
parent be2539602f
commit df969d9d73
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 93 additions and 138 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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