/ mlflow / assistant / cli.py
cli.py
  1  """MLflow CLI commands for Assistant integration."""
  2  
  3  import shutil
  4  import sys
  5  import threading
  6  import time
  7  from pathlib import Path
  8  
  9  import click
 10  
 11  from mlflow.assistant.config import AssistantConfig, ProjectConfig, SkillsConfig
 12  from mlflow.assistant.providers import AssistantProvider, list_providers
 13  from mlflow.assistant.providers.base import ProviderNotConfiguredError
 14  from mlflow.assistant.skill_installer import install_skills
 15  
 16  
 17  class Spinner:
 18      """Simple spinner animation for long-running operations."""
 19  
 20      def __init__(self, message: str = "Loading"):
 21          self.message = message
 22          self.spinning = False
 23          self.thread = None
 24          self.frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
 25  
 26      def _spin(self):
 27          i = 0
 28          while self.spinning:
 29              frame = self.frames[i % len(self.frames)]
 30              sys.stdout.write(f"\r{frame} {self.message}")
 31              sys.stdout.flush()
 32              time.sleep(0.1)
 33              i += 1
 34  
 35      def __enter__(self):
 36          self.spinning = True
 37          self.thread = threading.Thread(target=self._spin, name="Spinner")
 38          self.thread.start()
 39          return self
 40  
 41      def __exit__(self, *args):
 42          self.spinning = False
 43          if self.thread:
 44              self.thread.join()
 45          sys.stdout.write("\r" + " " * (len(self.message) + 4) + "\r")
 46          sys.stdout.flush()
 47  
 48  
 49  @click.command("assistant")
 50  @click.option(
 51      "--configure",
 52      is_flag=True,
 53      help="Configure or reconfigure the assistant settings",
 54  )
 55  def commands(configure: bool):
 56      """MLflow Assistant - AI-powered trace analysis.
 57  
 58      Run 'mlflow assistant --configure' to set up the assistant.
 59      """
 60      if configure:
 61          _run_configuration()
 62      else:
 63          # Check if already configured
 64          config = AssistantConfig.load()
 65          if not config.providers:
 66              click.secho(
 67                  "Assistant is not configured. Please run: mlflow assistant --configure",
 68                  fg="yellow",
 69              )
 70          else:
 71              click.secho(
 72                  "Assistant launch is not yet implemented. To use Assistant, run `mlflow assistant "
 73                  "--configure` to setup, then launch the MLflow UI manually.",
 74                  fg="yellow",
 75              )
 76  
 77  
 78  def _run_configuration():
 79      """Configure MLflow Assistant for the UI.
 80  
 81      This interactive command sets up the AI assistant feature that allows you
 82      to analyze MLflow traces directly from the UI.
 83  
 84      The command will:
 85      1. Ask which provider to use (Claude Code for now)
 86      2. Check provider availability
 87      3. Optionally connect an experiment with code repository
 88      4. Ask which model to use
 89      5. Ask where to install skills (user-level or project-level)
 90      6. Install provider-specific skills
 91      7. Save configuration
 92  
 93      Example:
 94          mlflow assistant --configure
 95      """
 96      click.echo()
 97      click.secho("╔══════════════════════════════════════════╗", fg="cyan")
 98      click.secho("║       *    .  *       .   *              ║", fg="cyan")
 99      click.secho("║   .    *  MLflow Assistant Setup   *  .  ║", fg="cyan", bold=True)
100      click.secho("║      *    .       *   .      *           ║", fg="cyan")
101      click.secho("╚══════════════════════════════════════════╝", fg="cyan")
102      click.echo()
103  
104      # Step 1: Select provider
105      provider = _prompt_provider()
106      if provider is None:
107          return
108  
109      # Step 2: Check provider availability
110      if not _check_provider(provider):
111          return
112  
113      # Step 3: Optionally connect experiment with code repository
114      project_path = _prompt_experiment_path()
115  
116      # Step 4: Ask for model
117      model = _prompt_model()
118  
119      # Step 5: Ask for skill location
120      skills_config = _prompt_skill_location(project_path)
121  
122      # Step 6: Install skills
123      skill_path = _install_skills(provider, skills_config, project_path)
124  
125      # Step 7: Save configuration
126      _save_config(provider, model, skills_config)
127  
128      # Show success message
129      _show_init_success(provider, model, skill_path)
130  
131  
132  def _prompt_provider() -> AssistantProvider | None:
133      """Prompt user to select a provider."""
134      providers = list_providers()
135  
136      click.secho("Step 1/4: Select AI Provider", fg="cyan", bold=True)
137      click.secho("-" * 30, fg="cyan")
138      click.echo()
139  
140      for i, provider in enumerate(providers, 1):
141          marker = click.style(" [recommended]", fg="green") if i == 1 else ""
142          click.echo(f"  {i}. {provider.display_name}{marker}")
143          click.secho(f"     {provider.description}", dim=True)
144  
145      click.echo()
146      click.secho("  More providers coming soon...", dim=True)
147      click.echo()
148  
149      default_provider = providers[0]
150      choice = click.prompt(
151          click.style(f"Select provider [1: {default_provider.display_name}]", fg="bright_blue"),
152          default="1",
153          type=click.Choice([str(i) for i in range(1, len(providers) + 1)]),
154          show_choices=False,
155          show_default=False,
156      )
157  
158      provider = providers[int(choice) - 1]
159      click.echo()
160      return provider
161  
162  
163  def _check_provider(provider: AssistantProvider) -> bool:
164      """Check if the selected provider is available."""
165      click.secho("Step 2/4: Checking Provider", fg="cyan", bold=True)
166      click.secho("-" * 30, fg="cyan")
167      click.echo()
168  
169      # First check if CLI is installed
170      claude_path = shutil.which("claude")
171      if not claude_path:
172          click.secho(
173              "Claude Code CLI is not installed. "
174              "Install it with: npm install -g @anthropic-ai/claude-code",
175              fg="red",
176          )
177          click.echo()
178          return False
179  
180      click.echo(f"Claude CLI found: {claude_path}")
181  
182      try:
183          spinner_msg = "Checking connection... " + click.style(
184              "(this may take a few seconds)", dim=True
185          )
186          with Spinner(spinner_msg):
187              provider.check_connection()
188          click.secho("Connection verified", fg="green")
189          click.echo()
190          return True
191      except ProviderNotConfiguredError as e:
192          click.secho(str(e), fg="red")
193          click.echo()
194          return False
195  
196  
197  def _fetch_recent_experiments(tracking_uri: str, max_results: int = 5) -> list[tuple[str, str]]:
198      """Fetch recent experiments from the tracking server.
199  
200      Returns:
201          List of (experiment_id, experiment_name) tuples.
202      """
203      import mlflow
204  
205      original_uri = mlflow.get_tracking_uri()
206      try:
207          mlflow.set_tracking_uri(tracking_uri)
208          client = mlflow.MlflowClient()
209          experiments = client.search_experiments(
210              max_results=max_results,
211              order_by=["last_update_time DESC"],
212          )
213          return [(exp.experiment_id, exp.name) for exp in experiments]
214      except Exception:
215          return []
216      finally:
217          mlflow.set_tracking_uri(original_uri)
218  
219  
220  def _resolve_experiment_id(tracking_uri: str, name_or_id: str) -> str | None:
221      """Resolve experiment name or ID to experiment ID.
222  
223      Args:
224          tracking_uri: MLflow tracking server URI.
225          name_or_id: Experiment name or ID.
226  
227      Returns:
228          Experiment ID if found, None otherwise.
229      """
230      import mlflow
231  
232      original_uri = mlflow.get_tracking_uri()
233      try:
234          mlflow.set_tracking_uri(tracking_uri)
235          client = mlflow.MlflowClient()
236  
237          # First try to get by ID (if it looks like an ID)
238          if name_or_id.isdigit():
239              try:
240                  if exp := client.get_experiment(name_or_id):
241                      return exp.experiment_id
242              except Exception:
243                  pass
244  
245          # Try to get by name
246          if exp := client.get_experiment_by_name(name_or_id):
247              return exp.experiment_id
248  
249          return None
250      except Exception:
251          return None
252      finally:
253          mlflow.set_tracking_uri(original_uri)
254  
255  
256  def _prompt_experiment_path() -> Path | None:
257      """Prompt user to optionally connect an experiment with code repository.
258  
259      Returns:
260          The project path if configured, None otherwise.
261      """
262      click.secho("Step 3/5: Experiment & Code Context ", fg="cyan", bold=True, nl=False)
263      click.secho("[Optional, Recommended]", fg="green", bold=True)
264      click.secho("-" * 30, fg="cyan")
265      click.echo()
266      click.echo("You can connect an experiment with a code repository to give")
267      click.echo("the assistant context about your source code for better analysis.")
268      click.secho("(You can also set this up later in the MLflow UI.)", dim=True)
269      click.echo()
270  
271      connect = click.confirm(
272          click.style(
273              "Do you want to connect an experiment with a code repository?", fg="bright_blue"
274          ),
275          default=True,
276      )
277  
278      if not connect:
279          click.echo()
280          return None
281  
282      click.echo()
283  
284      # Ask for tracking URI to fetch experiments
285      tracking_uri = click.prompt(
286          click.style("Enter the MLflow tracking server URI", fg="bright_blue"),
287          default="http://localhost:5000",
288      )
289  
290      click.echo()
291      click.secho("Fetching recent experiments...", dim=True)
292  
293      # Fetch recent experiments
294      experiments = _fetch_recent_experiments(tracking_uri)
295  
296      if not experiments:
297          click.secho("Could not fetch experiments from the server.", fg="yellow")
298          click.echo("You can set this up later in the MLflow UI.")
299          click.echo()
300          return None
301  
302      click.echo()
303      click.echo(click.style("Select an experiment to connect:", fg="bright_blue"))
304      click.echo()
305  
306      for i, (exp_id, exp_name) in enumerate(experiments, 1):
307          click.echo(f"  {i}. {exp_name} (ID: {exp_id})")
308  
309      other_option = len(experiments) + 1
310      click.echo(f"  {other_option}. Enter experiment name or ID manually")
311      click.echo()
312  
313      choice = click.prompt(
314          click.style("Select experiment", fg="bright_blue"),
315          type=click.IntRange(1, other_option),
316          default=1,
317      )
318  
319      if choice == other_option:
320          while True:
321              click.echo()
322              name_or_id = click.prompt(
323                  click.style("Experiment name or ID", fg="bright_blue"), default=""
324              )
325              if not name_or_id:
326                  click.secho("No experiment specified. Please try again.", fg="yellow")
327                  continue
328  
329              experiment_id = _resolve_experiment_id(tracking_uri, name_or_id)
330              if experiment_id:
331                  # Use the input as display name (could be name or ID)
332                  experiment_name = name_or_id
333                  break
334  
335              click.secho(
336                  f"Experiment '{name_or_id}' not found. Please try again.",
337                  fg="red",
338              )
339      else:
340          experiment_id, experiment_name = experiments[choice - 1]
341  
342      click.secho(
343          f"Experiment '{experiment_name}' selected",
344          fg="green",
345      )
346      click.echo()
347  
348      # Ask for project path
349      default_path = str(Path.cwd())
350      while True:
351          raw_path = click.prompt(
352              click.style("Enter the path to your project directory:", fg="bright_blue"),
353              default=default_path,
354          )
355          # Expand ~ and resolve relative paths
356          expanded_path = Path(raw_path).expanduser().resolve()
357          if expanded_path.is_dir():
358              project_path = str(expanded_path)
359              break
360          click.secho(f"Directory '{raw_path}' does not exist. Please try again.", fg="red")
361  
362      # Save the project path mapping locally
363      try:
364          config = AssistantConfig.load()
365          config.projects[experiment_id] = ProjectConfig(type="local", location=project_path)
366          config.save()
367          click.secho(
368              f"Project path {project_path} is saved for experiment '{experiment_name}'",
369              fg="green",
370          )
371      except Exception as e:
372          click.secho(f"Error saving project path: {e}", fg="red")
373  
374      click.echo()
375      return expanded_path
376  
377  
378  def _prompt_model() -> str:
379      """Prompt user for model selection."""
380      click.secho("Step 4/5: Model Selection", fg="cyan", bold=True)
381      click.secho("-" * 30, fg="cyan")
382      click.echo()
383      click.echo("Choose a model for analysis:")
384      click.secho("  - Press Enter to use the default model (recommended)", dim=True)
385      click.secho("  - Or type a specific model name (e.g., claude-sonnet-4-20250514)", dim=True)
386      click.echo()
387  
388      model = click.prompt(click.style("Model", fg="bright_blue"), default="default")
389      click.echo()
390      return model
391  
392  
393  def _prompt_skill_location(project_path: Path | None) -> SkillsConfig:
394      """Prompt user for skill installation location.
395  
396      Args:
397          project_path: The project path from experiment setup, or None if skipped.
398  
399      Returns:
400          SkillsConfig with the selected location type and optional custom path.
401      """
402      click.secho("Step 5/5: Skill Installation Location", fg="cyan", bold=True)
403      click.secho("-" * 30, fg="cyan")
404      click.echo()
405      click.echo("Choose where to install MLflow skills for Assistant:")
406      click.echo()
407  
408      # TODO: Update this when we support other providers
409      user_path = Path.home() / ".claude" / "skills"
410      click.echo(f"  1. User level ({user_path})")
411      click.secho("     Skills available globally across all projects", dim=True)
412      click.echo()
413  
414      if project_path:
415          project_skill_path = project_path / ".claude" / "skills"
416          click.echo(f"  2. Project level ({project_skill_path})")
417          click.secho("     Skills available only in this project", dim=True)
418          click.echo()
419          click.echo("  3. Custom location")
420          click.secho("     Specify a custom path for skills", dim=True)
421          click.echo()
422          valid_choices = ["1", "2", "3"]
423      else:
424          click.echo("  2. Custom location")
425          click.secho("     Specify a custom path for skills", dim=True)
426          click.echo()
427          valid_choices = ["1", "2"]
428  
429      choice = click.prompt(
430          click.style("Select location [1: User level]", fg="bright_blue"),
431          default="1",
432          type=click.Choice(valid_choices),
433          show_choices=False,
434          show_default=False,
435      )
436  
437      click.echo()
438  
439      if choice == "1":
440          return SkillsConfig(type="global")
441      elif choice == "2" and project_path:
442          return SkillsConfig(type="project")
443      else:
444          # Custom location
445          while True:
446              raw_path = click.prompt(
447                  click.style("Enter the custom path for skills", fg="bright_blue"),
448                  default=str(user_path),
449              )
450              expanded_path = Path(raw_path).expanduser().resolve()
451              # For custom paths, we'll create the directory, so just check parent exists
452              if expanded_path.parent.exists() or expanded_path.exists():
453                  click.echo()
454                  return SkillsConfig(type="custom", custom_path=str(expanded_path))
455              click.secho(
456                  f"Parent directory '{expanded_path.parent}' does not exist. Please try again.",
457                  fg="red",
458              )
459  
460  
461  def _install_skills(
462      provider: AssistantProvider, skills_config: SkillsConfig, project_path: Path | None
463  ) -> Path:
464      """Install skills bundled with MLflow.
465  
466      Returns:
467          The resolved path where skills were installed.
468      """
469      match skills_config.type:
470          case "global":
471              skill_path = provider.resolve_skills_path(Path.home())
472          case "project":
473              skill_path = provider.resolve_skills_path(project_path)
474          case "custom":
475              skill_path = Path(skills_config.custom_path).expanduser()
476      if installed_skills := install_skills(skill_path):
477          for skill in installed_skills:
478              click.secho(f"  - {skill}")
479      else:
480          click.secho("No skills available to install.", fg="yellow")
481      click.echo()
482      return skill_path
483  
484  
485  def _save_config(provider: AssistantProvider, model: str, skills_config: SkillsConfig) -> None:
486      """Save configuration to file."""
487      click.secho("Saving Configuration", fg="cyan", bold=True)
488      click.secho("-" * 30, fg="cyan")
489  
490      config = AssistantConfig.load()
491      config.set_provider(provider.name, model)
492      config.providers[provider.name].skills = skills_config
493      config.save()
494  
495      click.secho("Configuration saved", fg="green")
496      click.echo()
497  
498  
499  def _show_init_success(provider: AssistantProvider, model: str, skill_path: Path) -> None:
500      """Show success message and next steps."""
501      click.secho("  ~ * ~ * ~ * ~ * ~ * ~ * ~ * ~", fg="green")
502      click.secho("        Setup Complete!        ", fg="green", bold=True)
503      click.secho("  ~ * ~ * ~ * ~ * ~ * ~ * ~ * ~", fg="green")
504      click.echo()
505      click.secho("Configuration:", bold=True)
506      click.echo(f"  Provider: {provider.display_name}")
507      click.echo(f"  Model: {model}")
508      click.echo(f"  Skills: {skill_path}")
509      click.echo()
510      click.secho("Next steps:", bold=True)
511      click.echo("  1. Start MLflow server:")
512      click.secho("     $ mlflow server", fg="cyan")
513      click.echo()
514      click.echo("  2. Open MLflow UI and navigate to an experiment")
515      click.echo()
516      click.echo("  3. Click 'Ask Assistant'")
517      click.echo()
518      click.secho("To reconfigure, run: ", nl=False)
519      click.secho("mlflow assistant --configure", fg="cyan")