"""MLflow CLI commands for Assistant integration.""" import sys import threading import time from pathlib import Path import click from mlflow.assistant.config import AssistantConfig, ProjectConfig, SkillsConfig from mlflow.assistant.providers import AssistantProvider, list_providers from mlflow.assistant.providers.base import ProviderNotConfiguredError from mlflow.assistant.skill_installer import install_skills class Spinner: """Simple spinner animation for long-running operations.""" def __init__(self, message: str = "Loading"): self.message = message self.spinning = False self.thread = None self.frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] def _spin(self): i = 0 while self.spinning: frame = self.frames[i % len(self.frames)] sys.stdout.write(f"\r{frame} {self.message}") sys.stdout.flush() time.sleep(0.1) i += 1 def __enter__(self): self.spinning = True self.thread = threading.Thread(target=self._spin, name="Spinner") self.thread.start() return self def __exit__(self, *args): self.spinning = False if self.thread: self.thread.join() sys.stdout.write("\r" + " " * (len(self.message) + 4) + "\r") sys.stdout.flush() @click.command("assistant") @click.option( "--configure", is_flag=True, help="Configure or reconfigure the assistant settings", ) def commands(configure: bool): """MLflow Assistant - AI-powered trace analysis. Run 'mlflow assistant --configure' to set up the assistant. """ if configure: _run_configuration() else: # Check if already configured config = AssistantConfig.load() if not config.providers: click.secho( "Assistant is not configured. Please run: mlflow assistant --configure", fg="yellow", ) else: click.secho( "Assistant launch is not yet implemented. To use Assistant, run `mlflow assistant " "--configure` to setup, then launch the MLflow UI manually.", fg="yellow", ) def _run_configuration(): """Configure MLflow Assistant for the UI. This interactive command sets up the AI assistant feature that allows you to analyze MLflow traces directly from the UI. The command will: 1. Ask which provider to use (Claude Code for now) 2. Check provider availability 3. Optionally connect an experiment with code repository 4. Ask which model to use 5. Ask where to install skills (user-level or project-level) 6. Install provider-specific skills 7. Save configuration Example: mlflow assistant --configure """ click.echo() click.secho("╔══════════════════════════════════════════╗", fg="cyan") click.secho("║ * . * . * ║", fg="cyan") click.secho("║ . * MLflow Assistant Setup * . ║", fg="cyan", bold=True) click.secho("║ * . * . * ║", fg="cyan") click.secho("╚══════════════════════════════════════════╝", fg="cyan") click.echo() # Step 1: Select provider provider = _prompt_provider() if provider is None: return # Step 2: Check provider availability if not _check_provider(provider): return # Step 3: Optionally connect experiment with code repository project_path = _prompt_experiment_path() # Step 4: Ask for model model = _prompt_model() # Step 5: Ask for skill location skills_config = _prompt_skill_location(project_path) # Step 6: Install skills skill_path = _install_skills(provider, skills_config, project_path) # Step 7: Save configuration _save_config(provider, model, skills_config) # Show success message _show_init_success(provider, model, skill_path) def _prompt_provider() -> AssistantProvider | None: """Prompt user to select a provider.""" providers = list_providers() click.secho("Step 1/4: Select AI Provider", fg="cyan", bold=True) click.secho("-" * 30, fg="cyan") click.echo() for i, provider in enumerate(providers, 1): marker = click.style(" [recommended]", fg="green") if i == 1 else "" click.echo(f" {i}. {provider.display_name}{marker}") click.secho(f" {provider.description}", dim=True) click.echo() click.secho(" More providers coming soon...", dim=True) click.echo() default_provider = providers[0] choice = click.prompt( click.style(f"Select provider [1: {default_provider.display_name}]", fg="bright_blue"), default="1", type=click.Choice([str(i) for i in range(1, len(providers) + 1)]), show_choices=False, show_default=False, ) provider = providers[int(choice) - 1] click.echo() return provider def _check_provider(provider: AssistantProvider) -> bool: click.secho("Step 2/4: Checking Provider", fg="cyan", bold=True) click.secho("-" * 30, fg="cyan") click.echo() if not provider.is_available(): click.secho( f"{provider.display_name} is not available. " "Please ensure it is installed and accessible in your PATH.", fg="red", ) click.echo() return False try: spinner_msg = "Checking connection... " + click.style( "(this may take a few seconds)", dim=True ) with Spinner(spinner_msg): provider.check_connection() click.secho("Connection verified", fg="green") click.echo() return True except ProviderNotConfiguredError as e: click.secho(str(e), fg="red") click.echo() return False def _fetch_recent_experiments(tracking_uri: str, max_results: int = 5) -> list[tuple[str, str]]: """Fetch recent experiments from the tracking server. Returns: List of (experiment_id, experiment_name) tuples. """ import mlflow original_uri = mlflow.get_tracking_uri() try: mlflow.set_tracking_uri(tracking_uri) client = mlflow.MlflowClient() experiments = client.search_experiments( max_results=max_results, order_by=["last_update_time DESC"], ) return [(exp.experiment_id, exp.name) for exp in experiments] except Exception: return [] finally: mlflow.set_tracking_uri(original_uri) def _resolve_experiment_id(tracking_uri: str, name_or_id: str) -> str | None: """Resolve experiment name or ID to experiment ID. Args: tracking_uri: MLflow tracking server URI. name_or_id: Experiment name or ID. Returns: Experiment ID if found, None otherwise. """ import mlflow original_uri = mlflow.get_tracking_uri() try: mlflow.set_tracking_uri(tracking_uri) client = mlflow.MlflowClient() # First try to get by ID (if it looks like an ID) if name_or_id.isdigit(): try: if exp := client.get_experiment(name_or_id): return exp.experiment_id except Exception: pass # Try to get by name if exp := client.get_experiment_by_name(name_or_id): return exp.experiment_id return None except Exception: return None finally: mlflow.set_tracking_uri(original_uri) def _prompt_experiment_path() -> Path | None: """Prompt user to optionally connect an experiment with code repository. Returns: The project path if configured, None otherwise. """ click.secho("Step 3/5: Experiment & Code Context ", fg="cyan", bold=True, nl=False) click.secho("[Optional, Recommended]", fg="green", bold=True) click.secho("-" * 30, fg="cyan") click.echo() click.echo("You can connect an experiment with a code repository to give") click.echo("the assistant context about your source code for better analysis.") click.secho("(You can also set this up later in the MLflow UI.)", dim=True) click.echo() connect = click.confirm( click.style( "Do you want to connect an experiment with a code repository?", fg="bright_blue" ), default=True, ) if not connect: click.echo() return None click.echo() # Ask for tracking URI to fetch experiments tracking_uri = click.prompt( click.style("Enter the MLflow tracking server URI", fg="bright_blue"), default="http://localhost:5000", ) click.echo() click.secho("Fetching recent experiments...", dim=True) # Fetch recent experiments experiments = _fetch_recent_experiments(tracking_uri) if not experiments: click.secho("Could not fetch experiments from the server.", fg="yellow") click.echo("You can set this up later in the MLflow UI.") click.echo() return None click.echo() click.echo(click.style("Select an experiment to connect:", fg="bright_blue")) click.echo() for i, (exp_id, exp_name) in enumerate(experiments, 1): click.echo(f" {i}. {exp_name} (ID: {exp_id})") other_option = len(experiments) + 1 click.echo(f" {other_option}. Enter experiment name or ID manually") click.echo() choice = click.prompt( click.style("Select experiment", fg="bright_blue"), type=click.IntRange(1, other_option), default=1, ) if choice == other_option: while True: click.echo() name_or_id = click.prompt( click.style("Experiment name or ID", fg="bright_blue"), default="" ) if not name_or_id: click.secho("No experiment specified. Please try again.", fg="yellow") continue experiment_id = _resolve_experiment_id(tracking_uri, name_or_id) if experiment_id: # Use the input as display name (could be name or ID) experiment_name = name_or_id break click.secho( f"Experiment '{name_or_id}' not found. Please try again.", fg="red", ) else: experiment_id, experiment_name = experiments[choice - 1] click.secho( f"Experiment '{experiment_name}' selected", fg="green", ) click.echo() # Ask for project path default_path = str(Path.cwd()) while True: raw_path = click.prompt( click.style("Enter the path to your project directory:", fg="bright_blue"), default=default_path, ) # Expand ~ and resolve relative paths expanded_path = Path(raw_path).expanduser().resolve() if expanded_path.is_dir(): project_path = str(expanded_path) break click.secho(f"Directory '{raw_path}' does not exist. Please try again.", fg="red") # Save the project path mapping locally try: config = AssistantConfig.load() config.projects[experiment_id] = ProjectConfig(type="local", location=project_path) config.save() click.secho( f"Project path {project_path} is saved for experiment '{experiment_name}'", fg="green", ) except Exception as e: click.secho(f"Error saving project path: {e}", fg="red") click.echo() return expanded_path def _prompt_model() -> str: """Prompt user for model selection.""" click.secho("Step 4/5: Model Selection", fg="cyan", bold=True) click.secho("-" * 30, fg="cyan") click.echo() click.echo("Choose a model for analysis:") click.secho(" - Press Enter to use the default model (recommended)", dim=True) click.secho(" - Or type a specific model name (e.g., claude-sonnet-4-20250514)", dim=True) click.echo() model = click.prompt(click.style("Model", fg="bright_blue"), default="default") click.echo() return model def _prompt_skill_location(project_path: Path | None) -> SkillsConfig: """Prompt user for skill installation location. Args: project_path: The project path from experiment setup, or None if skipped. Returns: SkillsConfig with the selected location type and optional custom path. """ click.secho("Step 5/5: Skill Installation Location", fg="cyan", bold=True) click.secho("-" * 30, fg="cyan") click.echo() click.echo("Choose where to install MLflow skills for Assistant:") click.echo() # TODO: Update this when we support other providers user_path = Path.home() / ".claude" / "skills" click.echo(f" 1. User level ({user_path})") click.secho(" Skills available globally across all projects", dim=True) click.echo() if project_path: project_skill_path = project_path / ".claude" / "skills" click.echo(f" 2. Project level ({project_skill_path})") click.secho(" Skills available only in this project", dim=True) click.echo() click.echo(" 3. Custom location") click.secho(" Specify a custom path for skills", dim=True) click.echo() valid_choices = ["1", "2", "3"] else: click.echo(" 2. Custom location") click.secho(" Specify a custom path for skills", dim=True) click.echo() valid_choices = ["1", "2"] choice = click.prompt( click.style("Select location [1: User level]", fg="bright_blue"), default="1", type=click.Choice(valid_choices), show_choices=False, show_default=False, ) click.echo() if choice == "1": return SkillsConfig(type="global") elif choice == "2" and project_path: return SkillsConfig(type="project") else: # Custom location while True: raw_path = click.prompt( click.style("Enter the custom path for skills", fg="bright_blue"), default=str(user_path), ) expanded_path = Path(raw_path).expanduser().resolve() # For custom paths, we'll create the directory, so just check parent exists if expanded_path.parent.exists() or expanded_path.exists(): click.echo() return SkillsConfig(type="custom", custom_path=str(expanded_path)) click.secho( f"Parent directory '{expanded_path.parent}' does not exist. Please try again.", fg="red", ) def _install_skills( provider: AssistantProvider, skills_config: SkillsConfig, project_path: Path | None ) -> Path: """Install skills bundled with MLflow. Returns: The resolved path where skills were installed. """ match skills_config.type: case "global": skill_path = provider.resolve_skills_path(Path.home()) case "project": if project_path is None: raise ValueError("project_path is required for 'project' skills location") skill_path = provider.resolve_skills_path(project_path) case "custom": if skills_config.custom_path is None: raise ValueError("custom_path is required for 'custom' skills location") skill_path = Path(skills_config.custom_path).expanduser() if installed_skills := install_skills(skill_path): for skill in installed_skills: click.secho(f" - {skill}") else: click.secho("No skills available to install.", fg="yellow") click.echo() return skill_path def _save_config(provider: AssistantProvider, model: str, skills_config: SkillsConfig) -> None: """Save configuration to file.""" click.secho("Saving Configuration", fg="cyan", bold=True) click.secho("-" * 30, fg="cyan") config = AssistantConfig.load() config.set_provider(provider.name, model) config.providers[provider.name].skills = skills_config config.save() click.secho("Configuration saved", fg="green") click.echo() def _show_init_success(provider: AssistantProvider, model: str, skill_path: Path) -> None: """Show success message and next steps.""" click.secho(" ~ * ~ * ~ * ~ * ~ * ~ * ~ * ~", fg="green") click.secho(" Setup Complete! ", fg="green", bold=True) click.secho(" ~ * ~ * ~ * ~ * ~ * ~ * ~ * ~", fg="green") click.echo() click.secho("Configuration:", bold=True) click.echo(f" Provider: {provider.display_name}") click.echo(f" Model: {model}") click.echo(f" Skills: {skill_path}") click.echo() click.secho("Next steps:", bold=True) click.echo(" 1. Start MLflow server:") click.secho(" $ mlflow server", fg="cyan") click.echo() click.echo(" 2. Open MLflow UI and navigate to an experiment") click.echo() click.echo(" 3. Click 'Ask Assistant'") click.echo() click.secho("To reconfigure, run: ", nl=False) click.secho("mlflow assistant --configure", fg="cyan")