mcli-framework 7.10.1__py3-none-any.whl → 7.11.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of mcli-framework might be problematic. Click here for more details.

Files changed (43) hide show
  1. mcli/app/commands_cmd.py +150 -58
  2. mcli/app/main.py +21 -27
  3. mcli/lib/custom_commands.py +62 -12
  4. mcli/lib/optional_deps.py +240 -0
  5. mcli/lib/paths.py +129 -5
  6. mcli/self/migrate_cmd.py +261 -0
  7. mcli/self/self_cmd.py +8 -0
  8. mcli/workflow/git_commit/ai_service.py +13 -2
  9. mcli/workflow/notebook/__init__.py +16 -0
  10. mcli/workflow/notebook/converter.py +375 -0
  11. mcli/workflow/notebook/notebook_cmd.py +441 -0
  12. mcli/workflow/notebook/schema.py +402 -0
  13. mcli/workflow/notebook/validator.py +313 -0
  14. mcli/workflow/secrets/__init__.py +4 -0
  15. mcli/workflow/secrets/secrets_cmd.py +192 -0
  16. mcli/workflow/workflow.py +35 -5
  17. {mcli_framework-7.10.1.dist-info → mcli_framework-7.11.0.dist-info}/METADATA +86 -55
  18. {mcli_framework-7.10.1.dist-info → mcli_framework-7.11.0.dist-info}/RECORD +22 -34
  19. mcli/ml/features/political_features.py +0 -677
  20. mcli/ml/preprocessing/politician_trading_preprocessor.py +0 -570
  21. mcli/workflow/politician_trading/__init__.py +0 -4
  22. mcli/workflow/politician_trading/config.py +0 -134
  23. mcli/workflow/politician_trading/connectivity.py +0 -492
  24. mcli/workflow/politician_trading/data_sources.py +0 -654
  25. mcli/workflow/politician_trading/database.py +0 -412
  26. mcli/workflow/politician_trading/demo.py +0 -249
  27. mcli/workflow/politician_trading/models.py +0 -327
  28. mcli/workflow/politician_trading/monitoring.py +0 -413
  29. mcli/workflow/politician_trading/scrapers.py +0 -1074
  30. mcli/workflow/politician_trading/scrapers_california.py +0 -434
  31. mcli/workflow/politician_trading/scrapers_corporate_registry.py +0 -797
  32. mcli/workflow/politician_trading/scrapers_eu.py +0 -376
  33. mcli/workflow/politician_trading/scrapers_free_sources.py +0 -509
  34. mcli/workflow/politician_trading/scrapers_third_party.py +0 -373
  35. mcli/workflow/politician_trading/scrapers_uk.py +0 -378
  36. mcli/workflow/politician_trading/scrapers_us_states.py +0 -471
  37. mcli/workflow/politician_trading/seed_database.py +0 -520
  38. mcli/workflow/politician_trading/supabase_functions.py +0 -354
  39. mcli/workflow/politician_trading/workflow.py +0 -879
  40. {mcli_framework-7.10.1.dist-info → mcli_framework-7.11.0.dist-info}/WHEEL +0 -0
  41. {mcli_framework-7.10.1.dist-info → mcli_framework-7.11.0.dist-info}/entry_points.txt +0 -0
  42. {mcli_framework-7.10.1.dist-info → mcli_framework-7.11.0.dist-info}/licenses/LICENSE +0 -0
  43. {mcli_framework-7.10.1.dist-info → mcli_framework-7.11.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,240 @@
1
+ """
2
+ Utilities for graceful handling of optional dependencies.
3
+
4
+ This module provides helper functions and decorators to handle optional
5
+ dependencies gracefully, with clear error messages when features are unavailable.
6
+ """
7
+
8
+ import functools
9
+ from typing import Any, Callable, Dict, Optional, Tuple
10
+
11
+ from mcli.lib.logger.logger import get_logger
12
+
13
+ logger = get_logger(__name__)
14
+
15
+
16
+ class OptionalDependency:
17
+ """
18
+ Container for an optional dependency with availability tracking.
19
+
20
+ Example:
21
+ >>> ollama = OptionalDependency("ollama")
22
+ >>> if ollama.available:
23
+ ... client = ollama.module.Client()
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ module_name: str,
29
+ import_name: Optional[str] = None,
30
+ install_hint: Optional[str] = None,
31
+ ):
32
+ """
33
+ Initialize optional dependency handler.
34
+
35
+ Args:
36
+ module_name: Name of the module to import (e.g., "ollama")
37
+ import_name: Alternative import name if different from module_name
38
+ install_hint: Custom installation instruction
39
+ """
40
+ self.module_name = module_name
41
+ self.import_name = import_name or module_name
42
+ self.install_hint = install_hint or f"pip install {module_name}"
43
+ self.module: Optional[Any] = None
44
+ self.available = False
45
+ self.error: Optional[Exception] = None
46
+
47
+ self._try_import()
48
+
49
+ def _try_import(self):
50
+ """Attempt to import the module."""
51
+ try:
52
+ self.module = __import__(self.import_name)
53
+ self.available = True
54
+ logger.debug(f"Optional dependency '{self.module_name}' is available")
55
+ except ImportError as e:
56
+ self.available = False
57
+ self.error = e
58
+ logger.debug(f"Optional dependency '{self.module_name}' is not available: {e}")
59
+
60
+ def require(self, feature_name: Optional[str] = None) -> Any:
61
+ """
62
+ Require the dependency to be available, raising an error if not.
63
+
64
+ Args:
65
+ feature_name: Name of the feature requiring this dependency
66
+
67
+ Returns:
68
+ The imported module
69
+
70
+ Raises:
71
+ ImportError: If the dependency is not available
72
+ """
73
+ if not self.available:
74
+ feature_msg = f" for {feature_name}" if feature_name else ""
75
+ raise ImportError(
76
+ f"'{self.module_name}' is required{feature_msg} but not installed.\n"
77
+ f"Install it with: {self.install_hint}"
78
+ )
79
+ return self.module
80
+
81
+ def __getattr__(self, name: str) -> Any:
82
+ """Allow direct attribute access to the module."""
83
+ if not self.available:
84
+ raise ImportError(
85
+ f"Cannot access '{name}' from '{self.module_name}' - module not installed.\n"
86
+ f"Install it with: {self.install_hint}"
87
+ )
88
+ return getattr(self.module, name)
89
+
90
+
91
+ def optional_import(
92
+ module_name: str, import_name: Optional[str] = None, install_hint: Optional[str] = None
93
+ ) -> Tuple[Optional[Any], bool]:
94
+ """
95
+ Try to import an optional dependency.
96
+
97
+ Args:
98
+ module_name: Name of the module to import
99
+ import_name: Alternative import name if different from module_name
100
+ install_hint: Custom installation instruction
101
+
102
+ Returns:
103
+ Tuple of (module, available) where module is None if unavailable
104
+
105
+ Example:
106
+ >>> ollama, OLLAMA_AVAILABLE = optional_import("ollama")
107
+ >>> if OLLAMA_AVAILABLE:
108
+ ... client = ollama.Client()
109
+ """
110
+ dep = OptionalDependency(module_name, import_name, install_hint)
111
+ return (dep.module, dep.available)
112
+
113
+
114
+ def require_dependency(
115
+ module_name: str, feature_name: str, install_hint: Optional[str] = None
116
+ ) -> Any:
117
+ """
118
+ Require a dependency, raising clear error if not available.
119
+
120
+ Args:
121
+ module_name: Name of the module to import
122
+ feature_name: Name of the feature requiring this dependency
123
+ install_hint: Custom installation instruction
124
+
125
+ Returns:
126
+ The imported module
127
+
128
+ Raises:
129
+ ImportError: If the dependency is not available
130
+
131
+ Example:
132
+ >>> streamlit = require_dependency("streamlit", "dashboard")
133
+ """
134
+ dep = OptionalDependency(module_name, install_hint=install_hint)
135
+ return dep.require(feature_name)
136
+
137
+
138
+ def requires(*dependencies: str, install_all_hint: Optional[str] = None):
139
+ """
140
+ Decorator to mark a function as requiring specific dependencies.
141
+
142
+ Args:
143
+ *dependencies: Module names required by the function
144
+ install_all_hint: Custom installation instruction for all dependencies
145
+
146
+ Raises:
147
+ ImportError: If any required dependency is not available
148
+
149
+ Example:
150
+ >>> @requires("torch", "transformers")
151
+ ... def train_model():
152
+ ... import torch
153
+ ... import transformers
154
+ ... # training code
155
+ """
156
+
157
+ def decorator(func: Callable) -> Callable:
158
+ @functools.wraps(func)
159
+ def wrapper(*args, **kwargs):
160
+ missing = []
161
+ for dep_name in dependencies:
162
+ dep = OptionalDependency(dep_name)
163
+ if not dep.available:
164
+ missing.append(dep_name)
165
+
166
+ if missing:
167
+ if install_all_hint:
168
+ hint = install_all_hint
169
+ else:
170
+ hint = f"pip install {' '.join(missing)}"
171
+
172
+ raise ImportError(
173
+ f"Function '{func.__name__}' requires missing dependencies: {', '.join(missing)}\n"
174
+ f"Install them with: {hint}"
175
+ )
176
+
177
+ return func(*args, **kwargs)
178
+
179
+ return wrapper
180
+
181
+ return decorator
182
+
183
+
184
+ # Common optional dependencies registry
185
+ OPTIONAL_DEPS: Dict[str, OptionalDependency] = {}
186
+
187
+
188
+ def register_optional_dependency(
189
+ module_name: str, import_name: Optional[str] = None, install_hint: Optional[str] = None
190
+ ) -> OptionalDependency:
191
+ """
192
+ Register and cache an optional dependency.
193
+
194
+ Args:
195
+ module_name: Name of the module to import
196
+ import_name: Alternative import name if different from module_name
197
+ install_hint: Custom installation instruction
198
+
199
+ Returns:
200
+ OptionalDependency instance
201
+ """
202
+ if module_name not in OPTIONAL_DEPS:
203
+ OPTIONAL_DEPS[module_name] = OptionalDependency(module_name, import_name, install_hint)
204
+ return OPTIONAL_DEPS[module_name]
205
+
206
+
207
+ def check_dependencies(*module_names: str) -> Dict[str, bool]:
208
+ """
209
+ Check availability of multiple dependencies.
210
+
211
+ Args:
212
+ *module_names: Module names to check
213
+
214
+ Returns:
215
+ Dictionary mapping module names to availability status
216
+
217
+ Example:
218
+ >>> status = check_dependencies("torch", "transformers", "streamlit")
219
+ >>> print(status)
220
+ {'torch': True, 'transformers': False, 'streamlit': True}
221
+ """
222
+ return {
223
+ name: OptionalDependency(name).available for name in module_names
224
+ }
225
+
226
+
227
+ # Pre-register common optional dependencies
228
+ _COMMON_DEPS = {
229
+ "ollama": ("ollama", "pip install ollama"),
230
+ "streamlit": ("streamlit", "pip install streamlit"),
231
+ "torch": ("torch", "pip install torch"),
232
+ "transformers": ("transformers", "pip install transformers"),
233
+ "mlflow": ("mlflow", "pip install mlflow"),
234
+ "plotly": ("plotly", "pip install plotly"),
235
+ "pandas": ("pandas", "pip install pandas"),
236
+ "numpy": ("numpy", "pip install numpy"),
237
+ }
238
+
239
+ for module_name, (import_name, hint) in _COMMON_DEPS.items():
240
+ register_optional_dependency(module_name, import_name, hint)
mcli/lib/paths.py CHANGED
@@ -82,13 +82,137 @@ def get_cache_dir() -> Path:
82
82
  return cache_dir
83
83
 
84
84
 
85
- def get_custom_commands_dir() -> Path:
85
+ def is_git_repository(path: Optional[Path] = None) -> bool:
86
86
  """
87
- Get the custom commands directory for mcli.
87
+ Check if the current directory (or specified path) is inside a git repository.
88
+
89
+ Args:
90
+ path: Path to check (defaults to current working directory)
91
+
92
+ Returns:
93
+ True if inside a git repository, False otherwise
94
+ """
95
+ check_path = path or Path.cwd()
96
+
97
+ # Walk up the directory tree looking for .git
98
+ current = check_path.resolve()
99
+ while current != current.parent:
100
+ if (current / ".git").exists():
101
+ return True
102
+ current = current.parent
103
+
104
+ return False
105
+
106
+
107
+ def get_git_root(path: Optional[Path] = None) -> Optional[Path]:
108
+ """
109
+ Get the root directory of the git repository.
110
+
111
+ Args:
112
+ path: Path to check (defaults to current working directory)
113
+
114
+ Returns:
115
+ Path to git root, or None if not in a git repository
116
+ """
117
+ check_path = path or Path.cwd()
118
+
119
+ # Walk up the directory tree looking for .git
120
+ current = check_path.resolve()
121
+ while current != current.parent:
122
+ if (current / ".git").exists():
123
+ return current
124
+ current = current.parent
125
+
126
+ return None
127
+
128
+
129
+ def get_local_mcli_dir() -> Optional[Path]:
130
+ """
131
+ Get the local .mcli directory for the current git repository.
88
132
 
89
133
  Returns:
90
- Path to custom commands directory (e.g., ~/.mcli/commands), created if it doesn't exist
134
+ Path to .mcli directory in git root, or None if not in a git repository
91
135
  """
136
+ git_root = get_git_root()
137
+ if git_root:
138
+ local_mcli = git_root / ".mcli"
139
+ return local_mcli
140
+ return None
141
+
142
+
143
+ def get_local_commands_dir() -> Optional[Path]:
144
+ """
145
+ Get the local workflows directory for the current git repository.
146
+
147
+ Note: This function name is kept for backward compatibility but now returns
148
+ the workflows directory. Checks workflows first, then commands for migration.
149
+
150
+ Returns:
151
+ Path to .mcli/workflows (or .mcli/commands for migration) in git root,
152
+ or None if not in a git repository
153
+ """
154
+ local_mcli = get_local_mcli_dir()
155
+ if local_mcli:
156
+ # Check for new workflows directory first
157
+ workflows_dir = local_mcli / "workflows"
158
+ if workflows_dir.exists():
159
+ return workflows_dir
160
+
161
+ # Fall back to old commands directory for migration support
162
+ commands_dir = local_mcli / "commands"
163
+ return commands_dir
164
+ return None
165
+
166
+
167
+ def get_custom_commands_dir(global_mode: bool = False) -> Path:
168
+ """
169
+ Get the custom workflows directory for mcli.
170
+
171
+ Note: This function name is kept for backward compatibility but now returns
172
+ the workflows directory. Checks workflows first, then commands for migration.
173
+
174
+ Args:
175
+ global_mode: If True, always use global directory. If False, use local if in git repo.
176
+
177
+ Returns:
178
+ Path to custom workflows directory, created if it doesn't exist
179
+ """
180
+ # If not in global mode and we're in a git repository, use local directory
181
+ if not global_mode:
182
+ local_dir = get_local_commands_dir()
183
+ if local_dir:
184
+ local_dir.mkdir(parents=True, exist_ok=True)
185
+ return local_dir
186
+
187
+ # Otherwise, use global directory
188
+ # Check for new workflows directory first
189
+ workflows_dir = get_mcli_home() / "workflows"
190
+ if workflows_dir.exists():
191
+ return workflows_dir
192
+
193
+ # Check for old commands directory (for migration support)
92
194
  commands_dir = get_mcli_home() / "commands"
93
- commands_dir.mkdir(parents=True, exist_ok=True)
94
- return commands_dir
195
+ if commands_dir.exists():
196
+ # Return commands directory if it exists (user hasn't migrated yet)
197
+ return commands_dir
198
+
199
+ # If neither exists, create the new workflows directory
200
+ workflows_dir.mkdir(parents=True, exist_ok=True)
201
+ return workflows_dir
202
+
203
+
204
+ def get_lockfile_path(global_mode: bool = False) -> Path:
205
+ """
206
+ Get the lockfile path for workflow management.
207
+
208
+ Note: Lockfile remains named commands.lock.json for compatibility.
209
+
210
+ Args:
211
+ global_mode: If True, use global lockfile. If False, use local if in git repo.
212
+
213
+ Returns:
214
+ Path to the lockfile
215
+ """
216
+ workflows_dir = get_custom_commands_dir(global_mode=global_mode)
217
+ # Keep the old lockfile name for compatibility
218
+ return workflows_dir / "commands.lock.json"
@@ -0,0 +1,261 @@
1
+ """
2
+ Migration commands for mcli self-management.
3
+
4
+ Handles migrations between different versions of mcli, including:
5
+ - Directory structure changes
6
+ - Configuration format changes
7
+ - Command structure changes
8
+ """
9
+
10
+ import json
11
+ import shutil
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+ from typing import List, Tuple
15
+
16
+ import click
17
+ from rich.console import Console
18
+ from rich.panel import Panel
19
+ from rich.table import Table
20
+
21
+ from mcli.lib.logger.logger import get_logger
22
+ from mcli.lib.ui.styling import error, info, success, warning
23
+
24
+ logger = get_logger(__name__)
25
+ console = Console()
26
+
27
+
28
+ def get_migration_status() -> dict:
29
+ """
30
+ Check the current migration status.
31
+
32
+ Returns:
33
+ Dictionary with migration status information
34
+ """
35
+ mcli_home = Path.home() / ".mcli"
36
+ old_commands_dir = mcli_home / "commands"
37
+ new_workflows_dir = mcli_home / "workflows"
38
+
39
+ status = {
40
+ "old_dir_exists": old_commands_dir.exists(),
41
+ "old_dir_path": str(old_commands_dir),
42
+ "new_dir_exists": new_workflows_dir.exists(),
43
+ "new_dir_path": str(new_workflows_dir),
44
+ "needs_migration": False,
45
+ "files_to_migrate": [],
46
+ "migration_done": False,
47
+ }
48
+
49
+ # Check if migration is needed
50
+ if old_commands_dir.exists():
51
+ # Count files that need migration (excluding hidden files)
52
+ files = [
53
+ f for f in old_commands_dir.iterdir()
54
+ if f.is_file() and not f.name.startswith('.')
55
+ ]
56
+ status["files_to_migrate"] = [f.name for f in files]
57
+ status["needs_migration"] = len(files) > 0
58
+
59
+ # Check if migration already done
60
+ if new_workflows_dir.exists() and not old_commands_dir.exists():
61
+ status["migration_done"] = True
62
+
63
+ return status
64
+
65
+
66
+ def migrate_commands_to_workflows(dry_run: bool = False, force: bool = False) -> Tuple[bool, str]:
67
+ """
68
+ Migrate ~/.mcli/commands to ~/.mcli/workflows.
69
+
70
+ Args:
71
+ dry_run: If True, show what would be done without actually doing it
72
+ force: If True, proceed even if workflows directory exists
73
+
74
+ Returns:
75
+ Tuple of (success, message)
76
+ """
77
+ mcli_home = Path.home() / ".mcli"
78
+ old_dir = mcli_home / "commands"
79
+ new_dir = mcli_home / "workflows"
80
+
81
+ # Check if old directory exists
82
+ if not old_dir.exists():
83
+ return False, f"Nothing to migrate: {old_dir} does not exist"
84
+
85
+ # Check if new directory already exists
86
+ if new_dir.exists() and not force:
87
+ return False, f"Target directory {new_dir} already exists. Use --force to override."
88
+
89
+ # Get list of files to migrate
90
+ files_to_migrate = [
91
+ f for f in old_dir.iterdir()
92
+ if f.is_file() and not f.name.startswith('.')
93
+ ]
94
+
95
+ if not files_to_migrate:
96
+ return False, f"No files to migrate in {old_dir}"
97
+
98
+ if dry_run:
99
+ message = f"[DRY RUN] Would migrate {len(files_to_migrate)} files from {old_dir} to {new_dir}"
100
+ return True, message
101
+
102
+ try:
103
+ # Create new directory if it doesn't exist
104
+ new_dir.mkdir(parents=True, exist_ok=True)
105
+
106
+ # Track migrated files
107
+ migrated_files = []
108
+ skipped_files = []
109
+
110
+ # Move files
111
+ for file_path in files_to_migrate:
112
+ target_path = new_dir / file_path.name
113
+
114
+ # Check if file already exists in target
115
+ if target_path.exists():
116
+ if force:
117
+ # Backup existing file
118
+ backup_path = target_path.with_suffix(f".backup.{datetime.now().strftime('%Y%m%d%H%M%S')}")
119
+ shutil.move(str(target_path), str(backup_path))
120
+ logger.info(f"Backed up existing file to {backup_path}")
121
+ else:
122
+ skipped_files.append(file_path.name)
123
+ continue
124
+
125
+ # Move the file
126
+ shutil.move(str(file_path), str(target_path))
127
+ migrated_files.append(file_path.name)
128
+ logger.info(f"Migrated: {file_path.name}")
129
+
130
+ # Check if old directory is now empty (only hidden files remain)
131
+ remaining_files = [
132
+ f for f in old_dir.iterdir()
133
+ if f.is_file() and not f.name.startswith('.')
134
+ ]
135
+
136
+ # If empty, remove old directory
137
+ if not remaining_files:
138
+ # Keep hidden files like .gitignore but remove directory if truly empty
139
+ all_remaining = list(old_dir.iterdir())
140
+ if not all_remaining:
141
+ old_dir.rmdir()
142
+ logger.info(f"Removed empty directory: {old_dir}")
143
+
144
+ # Create migration report
145
+ report_lines = [
146
+ f"Successfully migrated {len(migrated_files)} files from {old_dir} to {new_dir}"
147
+ ]
148
+
149
+ if skipped_files:
150
+ report_lines.append(f"Skipped {len(skipped_files)} files (already exist in target)")
151
+
152
+ if remaining_files:
153
+ report_lines.append(f"Note: {len(remaining_files)} files remain in {old_dir}")
154
+
155
+ return True, "\n".join(report_lines)
156
+
157
+ except Exception as e:
158
+ logger.error(f"Migration failed: {e}")
159
+ return False, f"Migration failed: {str(e)}"
160
+
161
+
162
+ @click.command(name="migrate", help="Perform system migrations for mcli")
163
+ @click.option(
164
+ "--dry-run",
165
+ is_flag=True,
166
+ help="Show what would be done without actually doing it",
167
+ )
168
+ @click.option(
169
+ "--force",
170
+ is_flag=True,
171
+ help="Force migration even if target directory exists",
172
+ )
173
+ @click.option(
174
+ "--status",
175
+ is_flag=True,
176
+ help="Show migration status without performing migration",
177
+ )
178
+ def migrate_command(dry_run: bool, force: bool, status: bool):
179
+ """
180
+ Migrate mcli configuration and data to new structure.
181
+
182
+ Currently handles:
183
+ - Moving ~/.mcli/commands to ~/.mcli/workflows
184
+
185
+ Examples:
186
+ mcli self migrate --status # Check migration status
187
+ mcli self migrate --dry-run # See what would be done
188
+ mcli self migrate # Perform migration
189
+ mcli self migrate --force # Force migration (overwrite existing)
190
+ """
191
+
192
+ # Get current status
193
+ migration_status = get_migration_status()
194
+
195
+ # If --status flag, just show status and exit
196
+ if status:
197
+ console.print("\n[bold cyan]Migration Status[/bold cyan]")
198
+ console.print(f"\n[bold]Old location:[/bold] {migration_status['old_dir_path']}")
199
+ console.print(f" Exists: {'✓ Yes' if migration_status['old_dir_exists'] else '✗ No'}")
200
+
201
+ console.print(f"\n[bold]New location:[/bold] {migration_status['new_dir_path']}")
202
+ console.print(f" Exists: {'✓ Yes' if migration_status['new_dir_exists'] else '✗ No'}")
203
+
204
+ if migration_status['needs_migration']:
205
+ console.print(f"\n[yellow]⚠ Migration needed[/yellow]")
206
+ console.print(f"Files to migrate: {len(migration_status['files_to_migrate'])}")
207
+
208
+ if migration_status['files_to_migrate']:
209
+ table = Table(title="Files to Migrate")
210
+ table.add_column("File Name", style="cyan")
211
+
212
+ for filename in sorted(migration_status['files_to_migrate']):
213
+ table.add_row(filename)
214
+
215
+ console.print(table)
216
+
217
+ console.print(f"\n[dim]Run 'mcli self migrate' to perform migration[/dim]")
218
+ elif migration_status['migration_done']:
219
+ console.print(f"\n[green]✓ Migration already completed[/green]")
220
+ else:
221
+ console.print(f"\n[green]✓ No migration needed[/green]")
222
+
223
+ return
224
+
225
+ # Check if migration is needed
226
+ if not migration_status['needs_migration']:
227
+ if migration_status['migration_done']:
228
+ info("Migration already completed")
229
+ info(f"Workflows directory: {migration_status['new_dir_path']}")
230
+ else:
231
+ info("No migration needed")
232
+ return
233
+
234
+ # Show what will be migrated
235
+ console.print("\n[bold cyan]Migration Plan[/bold cyan]")
236
+ console.print(f"\nSource: [cyan]{migration_status['old_dir_path']}[/cyan]")
237
+ console.print(f"Target: [cyan]{migration_status['new_dir_path']}[/cyan]")
238
+ console.print(f"Files: [yellow]{len(migration_status['files_to_migrate'])}[/yellow]")
239
+
240
+ if dry_run:
241
+ console.print(f"\n[yellow]DRY RUN MODE - No changes will be made[/yellow]")
242
+
243
+ # Perform migration
244
+ success_flag, message = migrate_commands_to_workflows(dry_run=dry_run, force=force)
245
+
246
+ if success_flag:
247
+ if dry_run:
248
+ info(message)
249
+ else:
250
+ success(message)
251
+ console.print("\n[green]✓ Migration completed successfully[/green]")
252
+ console.print(f"\nYour workflows are now in: [cyan]{migration_status['new_dir_path']}[/cyan]")
253
+ console.print("\n[dim]You can now use 'mcli workflow' to manage and 'mcli workflows' to run them[/dim]")
254
+ else:
255
+ error(message)
256
+ if not force and "already exists" in message:
257
+ console.print("\n[yellow]Tip: Use --force to proceed anyway (will backup existing files)[/yellow]")
258
+
259
+
260
+ if __name__ == "__main__":
261
+ migrate_command()
mcli/self/self_cmd.py CHANGED
@@ -1045,6 +1045,14 @@ try:
1045
1045
  except ImportError as e:
1046
1046
  logger.debug(f"Could not load visual command: {e}")
1047
1047
 
1048
+ try:
1049
+ from mcli.self.migrate_cmd import migrate_command
1050
+
1051
+ self_app.add_command(migrate_command, name="migrate")
1052
+ logger.debug("Added migrate command to self group")
1053
+ except ImportError as e:
1054
+ logger.debug(f"Could not load migrate command: {e}")
1055
+
1048
1056
  # NOTE: store command has been moved to mcli.app.commands_cmd for better organization
1049
1057
 
1050
1058
  # This part is important to make the command available to the CLI
@@ -2,11 +2,13 @@ import json
2
2
  import logging
3
3
  from typing import Any, Dict, Optional
4
4
 
5
- import ollama
6
-
7
5
  from mcli.lib.logger.logger import get_logger
6
+ from mcli.lib.optional_deps import optional_import
8
7
  from mcli.lib.toml.toml import read_from_toml
9
8
 
9
+ # Gracefully handle optional ollama dependency
10
+ ollama, OLLAMA_AVAILABLE = optional_import("ollama")
11
+
10
12
  logger = get_logger(__name__)
11
13
 
12
14
 
@@ -204,6 +206,15 @@ Generate ONLY the commit message, nothing else:"""
204
206
  def generate_commit_message(self, changes: Dict[str, Any], diff_content: str) -> str:
205
207
  """Generate an AI-powered commit message"""
206
208
  try:
209
+ # Check if ollama is available
210
+ if not OLLAMA_AVAILABLE:
211
+ logger.warning(
212
+ "Ollama is not installed. Install it with: pip install ollama\n"
213
+ "Falling back to rule-based commit message generation."
214
+ )
215
+ analysis = self._analyze_file_patterns(changes)
216
+ return self._generate_fallback_message(changes, analysis)
217
+
207
218
  # Analyze the changes first
208
219
  analysis = self._analyze_file_patterns(changes)
209
220