mcli-framework 7.11.4__py3-none-any.whl → 7.12.1__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 (82) hide show
  1. mcli/__init__.py +160 -0
  2. mcli/__main__.py +14 -0
  3. mcli/app/__init__.py +23 -0
  4. mcli/app/commands_cmd.py +18 -823
  5. mcli/app/init_cmd.py +391 -0
  6. mcli/app/lock_cmd.py +288 -0
  7. mcli/app/main.py +39 -42
  8. mcli/app/model/__init__.py +0 -0
  9. mcli/app/store_cmd.py +448 -0
  10. mcli/app/video/__init__.py +5 -0
  11. mcli/chat/__init__.py +34 -0
  12. mcli/lib/__init__.py +0 -0
  13. mcli/lib/api/__init__.py +0 -0
  14. mcli/lib/auth/__init__.py +1 -0
  15. mcli/lib/config/__init__.py +1 -0
  16. mcli/lib/custom_commands.py +3 -3
  17. mcli/lib/erd/__init__.py +25 -0
  18. mcli/lib/files/__init__.py +0 -0
  19. mcli/lib/fs/__init__.py +1 -0
  20. mcli/lib/logger/__init__.py +3 -0
  21. mcli/lib/optional_deps.py +1 -3
  22. mcli/lib/performance/__init__.py +17 -0
  23. mcli/lib/pickles/__init__.py +1 -0
  24. mcli/lib/secrets/__init__.py +10 -0
  25. mcli/lib/shell/__init__.py +0 -0
  26. mcli/lib/toml/__init__.py +1 -0
  27. mcli/lib/watcher/__init__.py +0 -0
  28. mcli/ml/__init__.py +16 -0
  29. mcli/ml/api/__init__.py +30 -0
  30. mcli/ml/api/routers/__init__.py +27 -0
  31. mcli/ml/auth/__init__.py +41 -0
  32. mcli/ml/backtesting/__init__.py +33 -0
  33. mcli/ml/cli/__init__.py +5 -0
  34. mcli/ml/config/__init__.py +33 -0
  35. mcli/ml/configs/__init__.py +16 -0
  36. mcli/ml/dashboard/__init__.py +12 -0
  37. mcli/ml/dashboard/components/__init__.py +7 -0
  38. mcli/ml/dashboard/pages/__init__.py +6 -0
  39. mcli/ml/data_ingestion/__init__.py +29 -0
  40. mcli/ml/database/__init__.py +40 -0
  41. mcli/ml/experimentation/__init__.py +29 -0
  42. mcli/ml/features/__init__.py +39 -0
  43. mcli/ml/mlops/__init__.py +19 -0
  44. mcli/ml/models/__init__.py +90 -0
  45. mcli/ml/monitoring/__init__.py +25 -0
  46. mcli/ml/optimization/__init__.py +27 -0
  47. mcli/ml/predictions/__init__.py +5 -0
  48. mcli/ml/preprocessing/__init__.py +24 -0
  49. mcli/ml/scripts/__init__.py +1 -0
  50. mcli/ml/serving/__init__.py +1 -0
  51. mcli/ml/trading/__init__.py +63 -0
  52. mcli/ml/training/__init__.py +7 -0
  53. mcli/mygroup/__init__.py +3 -0
  54. mcli/public/__init__.py +1 -0
  55. mcli/public/commands/__init__.py +2 -0
  56. mcli/self/__init__.py +3 -0
  57. mcli/self/migrate_cmd.py +209 -76
  58. mcli/self/self_cmd.py +52 -0
  59. mcli/workflow/__init__.py +0 -0
  60. mcli/workflow/daemon/__init__.py +15 -0
  61. mcli/workflow/dashboard/__init__.py +5 -0
  62. mcli/workflow/docker/__init__.py +0 -0
  63. mcli/workflow/file/__init__.py +0 -0
  64. mcli/workflow/gcloud/__init__.py +1 -0
  65. mcli/workflow/git_commit/__init__.py +0 -0
  66. mcli/workflow/interview/__init__.py +0 -0
  67. mcli/workflow/notebook/__init__.py +16 -0
  68. mcli/workflow/registry/__init__.py +0 -0
  69. mcli/workflow/repo/__init__.py +0 -0
  70. mcli/workflow/scheduler/__init__.py +25 -0
  71. mcli/workflow/search/__init__.py +0 -0
  72. mcli/workflow/secrets/__init__.py +5 -0
  73. mcli/workflow/secrets/secrets_cmd.py +1 -2
  74. mcli/workflow/sync/__init__.py +5 -0
  75. mcli/workflow/videos/__init__.py +1 -0
  76. mcli/workflow/wakatime/__init__.py +80 -0
  77. {mcli_framework-7.11.4.dist-info → mcli_framework-7.12.1.dist-info}/METADATA +10 -10
  78. {mcli_framework-7.11.4.dist-info → mcli_framework-7.12.1.dist-info}/RECORD +82 -13
  79. {mcli_framework-7.11.4.dist-info → mcli_framework-7.12.1.dist-info}/WHEEL +0 -0
  80. {mcli_framework-7.11.4.dist-info → mcli_framework-7.12.1.dist-info}/entry_points.txt +0 -0
  81. {mcli_framework-7.11.4.dist-info → mcli_framework-7.12.1.dist-info}/licenses/LICENSE +0 -0
  82. {mcli_framework-7.11.4.dist-info → mcli_framework-7.12.1.dist-info}/top_level.txt +0 -0
mcli/app/init_cmd.py ADDED
@@ -0,0 +1,391 @@
1
+ """
2
+ Top-level initialization and teardown commands for MCLI.
3
+ """
4
+ import json
5
+ import shutil
6
+ import subprocess
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+
10
+ import click
11
+ from rich.prompt import Prompt
12
+
13
+ from mcli.lib.logger.logger import get_logger
14
+ from mcli.lib.ui.styling import console
15
+
16
+ logger = get_logger(__name__)
17
+
18
+
19
+ @click.command("init")
20
+ @click.option(
21
+ "--global",
22
+ "-g",
23
+ "is_global",
24
+ is_flag=True,
25
+ help="Initialize global workflows directory instead of local",
26
+ )
27
+ @click.option("--git", is_flag=True, help="Initialize git repository in workflows directory")
28
+ @click.option("--force", "-f", is_flag=True, help="Force initialization even if directory exists")
29
+ def init(is_global, git, force):
30
+ """
31
+ Initialize workflows directory structure.
32
+
33
+ Creates the necessary directories and configuration files for managing
34
+ custom workflows. By default, creates a local .mcli/workflows/ directory
35
+ if in a git repository, otherwise uses ~/.mcli/workflows/.
36
+
37
+ Examples:
38
+ mcli init # Initialize local workflows (if in git repo)
39
+ mcli init --global # Initialize global workflows
40
+ mcli init --git # Also initialize git repository
41
+ """
42
+ from mcli.lib.paths import get_git_root, get_local_mcli_dir, get_mcli_home, is_git_repository
43
+
44
+ # Determine if we're in a git repository
45
+ in_git_repo = is_git_repository() and not is_global
46
+ git_root = get_git_root() if in_git_repo else None
47
+
48
+ # Explicitly create workflows directory (not commands)
49
+ # This bypasses the migration logic that would check for old commands/ directory
50
+ if not is_global and in_git_repo:
51
+ local_mcli = get_local_mcli_dir()
52
+ workflows_dir = local_mcli / "workflows"
53
+ else:
54
+ workflows_dir = get_mcli_home() / "workflows"
55
+
56
+ lockfile_path = workflows_dir / "commands.lock.json"
57
+
58
+ # Check if already initialized
59
+ if workflows_dir.exists() and not force:
60
+ if lockfile_path.exists():
61
+ console.print(
62
+ f"[yellow]Workflows directory already initialized at:[/yellow] {workflows_dir}"
63
+ )
64
+ console.print(f"[dim]Use --force to reinitialize[/dim]")
65
+
66
+ should_continue = Prompt.ask("Continue anyway?", choices=["y", "n"], default="n")
67
+ if should_continue.lower() != "y":
68
+ return 0
69
+
70
+ # Create workflows directory
71
+ workflows_dir.mkdir(parents=True, exist_ok=True)
72
+ console.print(f"[green]✓[/green] Created workflows directory: {workflows_dir}")
73
+
74
+ # Create README.md
75
+ readme_path = workflows_dir / "README.md"
76
+ if not readme_path.exists() or force:
77
+ scope = "local" if in_git_repo else "global"
78
+ scope_desc = f"for repository: {git_root.name}" if in_git_repo else "globally"
79
+
80
+ readme_content = f"""# MCLI Custom Workflows
81
+
82
+ This directory contains custom workflow commands {scope_desc}.
83
+
84
+ ## Quick Start
85
+
86
+ ### Create a New Workflow
87
+
88
+ ```bash
89
+ # Python workflow
90
+ mcli workflow add my-workflow
91
+
92
+ # Shell workflow
93
+ mcli workflow add my-script --language shell
94
+ ```
95
+
96
+ ### List Workflows
97
+
98
+ ```bash
99
+ mcli workflow list --custom-only
100
+ ```
101
+
102
+ ### Execute a Workflow
103
+
104
+ ```bash
105
+ mcli workflows my-workflow
106
+ ```
107
+
108
+ ### Edit a Workflow
109
+
110
+ ```bash
111
+ mcli workflow edit my-workflow
112
+ ```
113
+
114
+ ### Export/Import Workflows
115
+
116
+ ```bash
117
+ # Export all workflows
118
+ mcli workflow export workflows-backup.json
119
+
120
+ # Import workflows
121
+ mcli workflow import workflows-backup.json
122
+ ```
123
+
124
+ ## Directory Structure
125
+
126
+ ```
127
+ {workflows_dir.name}/
128
+ ├── README.md # This file
129
+ ├── commands.lock.json # Lockfile for workflow state
130
+ └── *.json # Individual workflow definitions
131
+ ```
132
+
133
+ ## Workflow Format
134
+
135
+ Workflows are stored as JSON files with the following structure:
136
+
137
+ ```json
138
+ {{
139
+ "name": "workflow-name",
140
+ "description": "Workflow description",
141
+ "code": "Python or shell code",
142
+ "language": "python",
143
+ "group": "workflow",
144
+ "version": "1.0",
145
+ "created_at": "2025-10-30T...",
146
+ "updated_at": "2025-10-30T..."
147
+ }}
148
+ ```
149
+
150
+ ## Scope
151
+
152
+ - **Scope**: {'Local (repository-specific)' if in_git_repo else 'Global (user-wide)'}
153
+ - **Location**: `{workflows_dir}`
154
+ {f"- **Git Repository**: `{git_root}`" if git_root else ""}
155
+
156
+ ## Documentation
157
+
158
+ - [MCLI Documentation](https://github.com/gwicho38/mcli)
159
+ - [Workflow Guide](https://github.com/gwicho38/mcli/blob/main/docs/features/LOCAL_VS_GLOBAL_COMMANDS.md)
160
+
161
+ ---
162
+
163
+ *Initialized: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*
164
+ """
165
+
166
+ readme_path.write_text(readme_content)
167
+ console.print(f"[green]✓[/green] Created README: {readme_path.name}")
168
+
169
+ # Initialize lockfile
170
+ if not lockfile_path.exists() or force:
171
+ lockfile_data = {
172
+ "version": "1.0",
173
+ "initialized_at": datetime.now().isoformat(),
174
+ "scope": "local" if in_git_repo else "global",
175
+ "commands": {},
176
+ }
177
+
178
+ with open(lockfile_path, "w") as f:
179
+ json.dump(lockfile_data, f, indent=2)
180
+
181
+ console.print(f"[green]✓[/green] Initialized lockfile: {lockfile_path.name}")
182
+
183
+ # Create .gitignore if in workflows directory
184
+ gitignore_path = workflows_dir / ".gitignore"
185
+ if not gitignore_path.exists() or force:
186
+ gitignore_content = """# Backup files
187
+ *.backup
188
+ *.bak
189
+
190
+ # Temporary files
191
+ *.tmp
192
+ *.temp
193
+
194
+ # OS files
195
+ .DS_Store
196
+ Thumbs.db
197
+
198
+ # Editor files
199
+ *.swp
200
+ *.swo
201
+ *~
202
+ .vscode/
203
+ .idea/
204
+ """
205
+ gitignore_path.write_text(gitignore_content)
206
+ console.print(f"[green]✓[/green] Created .gitignore")
207
+
208
+ # Initialize git if requested
209
+ if git and not (workflows_dir / ".git").exists():
210
+ try:
211
+ subprocess.run(["git", "init"], cwd=workflows_dir, check=True, capture_output=True)
212
+ console.print(f"[green]✓[/green] Initialized git repository in workflows directory")
213
+
214
+ # Create initial commit
215
+ subprocess.run(["git", "add", "."], cwd=workflows_dir, check=True, capture_output=True)
216
+ subprocess.run(
217
+ ["git", "commit", "-m", "Initial commit: Initialize workflows directory"],
218
+ cwd=workflows_dir,
219
+ check=True,
220
+ capture_output=True,
221
+ )
222
+ console.print(f"[green]✓[/green] Created initial commit")
223
+
224
+ except subprocess.CalledProcessError as e:
225
+ console.print(f"[yellow]⚠[/yellow] Git initialization failed: {e}")
226
+ except FileNotFoundError:
227
+ console.print(f"[yellow]⚠[/yellow] Git not found. Skipping git initialization.")
228
+
229
+ # Summary
230
+ from rich.table import Table
231
+
232
+ console.print()
233
+ console.print("[bold green]Workflows directory initialized successfully![/bold green]")
234
+ console.print()
235
+
236
+ # Display summary table
237
+ table = Table(title="Initialization Summary", show_header=False)
238
+ table.add_column("Property", style="cyan")
239
+ table.add_column("Value", style="green")
240
+
241
+ table.add_row("Scope", "Local (repository-specific)" if in_git_repo else "Global (user-wide)")
242
+ table.add_row("Location", str(workflows_dir))
243
+ if git_root:
244
+ table.add_row("Git Repository", str(git_root))
245
+ table.add_row("Lockfile", str(lockfile_path))
246
+ table.add_row("Git Initialized", "Yes" if git and (workflows_dir / ".git").exists() else "No")
247
+
248
+ console.print(table)
249
+ console.print()
250
+
251
+ # Next steps
252
+ console.print("[bold]Next Steps:[/bold]")
253
+ console.print(" 1. Create a workflow: [cyan]mcli workflow add my-workflow[/cyan]")
254
+ console.print(" 2. List workflows: [cyan]mcli workflow list --custom-only[/cyan]")
255
+ console.print(" 3. Execute workflow: [cyan]mcli workflows my-workflow[/cyan]")
256
+ console.print(" 4. View README: [cyan]cat {}/README.md[/cyan]".format(workflows_dir))
257
+ console.print()
258
+
259
+ if in_git_repo:
260
+ console.print(
261
+ f"[dim]Tip: Workflows are local to this repository. Use --global for user-wide workflows.[/dim]"
262
+ )
263
+ else:
264
+ console.print(
265
+ f"[dim]Tip: Use workflows in any git repository, or create local ones with 'mcli init' inside repos.[/dim]"
266
+ )
267
+
268
+ return 0
269
+
270
+
271
+ @click.command("teardown")
272
+ @click.option(
273
+ "--global",
274
+ "-g",
275
+ "is_global",
276
+ is_flag=True,
277
+ help="Teardown global workflows directory instead of local",
278
+ )
279
+ @click.option("--force", "-f", is_flag=True, help="Skip all confirmation prompts")
280
+ def teardown(is_global, force):
281
+ """
282
+ Remove all local MCLI artifacts.
283
+
284
+ This command deletes the workflows directory and all associated files.
285
+ By default, operates on local workflows (if in git repo), use --global for
286
+ global workflows.
287
+
288
+ For safety, you must type the name of the current directory to confirm.
289
+
290
+ Examples:
291
+ mcli teardown # Remove local workflows (requires confirmation)
292
+ mcli teardown --global # Remove global workflows (requires confirmation)
293
+ mcli teardown --force # Skip confirmations (dangerous!)
294
+ """
295
+ from mcli.lib.paths import get_git_root, get_local_mcli_dir, get_mcli_home, is_git_repository
296
+
297
+ # Determine which directory to teardown
298
+ in_git_repo = is_git_repository() and not is_global
299
+ git_root = get_git_root() if in_git_repo else None
300
+
301
+ if not is_global and in_git_repo:
302
+ local_mcli = get_local_mcli_dir()
303
+ workflows_dir = local_mcli / "workflows"
304
+ scope = "local"
305
+ scope_display = git_root.name if git_root else "current repository"
306
+ else:
307
+ workflows_dir = get_mcli_home() / "workflows"
308
+ scope = "global"
309
+ scope_display = "global (~/.mcli)"
310
+
311
+ # Check if workflows directory exists
312
+ if not workflows_dir.exists():
313
+ console.print(f"[yellow]No workflows directory found at:[/yellow] {workflows_dir}")
314
+ console.print("[dim]Nothing to teardown.[/dim]")
315
+ return 0
316
+
317
+ # Display what will be removed
318
+ console.print(f"[bold red]⚠ WARNING: This will delete all {scope} MCLI artifacts[/bold red]")
319
+ console.print()
320
+ console.print(f"[bold]Scope:[/bold] {scope_display}")
321
+ console.print(f"[bold]Directory:[/bold] {workflows_dir}")
322
+
323
+ # Count files
324
+ try:
325
+ file_count = sum(1 for _ in workflows_dir.rglob("*") if _.is_file())
326
+ console.print(f"[bold]Files to delete:[/bold] {file_count}")
327
+ except Exception:
328
+ file_count = "unknown"
329
+ console.print(f"[bold]Files to delete:[/bold] {file_count}")
330
+
331
+ console.print()
332
+
333
+ # Safety confirmation
334
+ if not force:
335
+ console.print("[yellow]This action cannot be undone![/yellow]")
336
+ console.print()
337
+
338
+ if in_git_repo and git_root:
339
+ # For local repos, require typing the repo name
340
+ expected_name = git_root.name
341
+ console.print(
342
+ f"[bold]To confirm, type the repository name:[/bold] [cyan]{expected_name}[/cyan]"
343
+ )
344
+ else:
345
+ # For global, require typing "global"
346
+ expected_name = "global"
347
+ console.print(f"[bold]To confirm, type:[/bold] [cyan]{expected_name}[/cyan]")
348
+
349
+ confirmation = Prompt.ask("Confirmation")
350
+
351
+ if confirmation != expected_name:
352
+ console.print("[red]Confirmation failed. Teardown cancelled.[/red]")
353
+ return 1
354
+
355
+ # Perform teardown
356
+ try:
357
+ console.print()
358
+ console.print("[yellow]Removing workflows directory...[/yellow]")
359
+
360
+ shutil.rmtree(workflows_dir)
361
+
362
+ console.print(f"[green]✓[/green] Removed: {workflows_dir}")
363
+
364
+ # Also remove parent .mcli directory if empty (local only)
365
+ if not is_global and in_git_repo:
366
+ try:
367
+ parent_dir = workflows_dir.parent
368
+ if parent_dir.exists() and not any(parent_dir.iterdir()):
369
+ parent_dir.rmdir()
370
+ console.print(f"[green]✓[/green] Removed empty directory: {parent_dir}")
371
+ except OSError:
372
+ pass # Directory not empty, that's fine
373
+
374
+ console.print()
375
+ console.print("[bold green]Teardown complete![/bold green]")
376
+
377
+ if in_git_repo:
378
+ console.print(
379
+ "[dim]Local workflows removed. Global workflows (if any) are still available.[/dim]"
380
+ )
381
+ else:
382
+ console.print(
383
+ "[dim]Global workflows removed. Local workflows (if any) are still available.[/dim]"
384
+ )
385
+
386
+ return 0
387
+
388
+ except Exception as e:
389
+ console.print(f"[red]Error during teardown: {e}[/red]")
390
+ logger.exception(e)
391
+ return 1
mcli/app/lock_cmd.py ADDED
@@ -0,0 +1,288 @@
1
+ """
2
+ Top-level lock management commands for MCLI.
3
+ Manages workflow lockfile and verification.
4
+ """
5
+ import hashlib
6
+ import json
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+
10
+ import click
11
+ from rich.table import Table
12
+
13
+ from mcli.lib.custom_commands import get_command_manager
14
+ from mcli.lib.logger.logger import get_logger
15
+ from mcli.lib.ui.styling import console
16
+
17
+ logger = get_logger(__name__)
18
+
19
+ # Command state lockfile configuration
20
+ LOCKFILE_PATH = Path.home() / ".local" / "mcli" / "command_lock.json"
21
+
22
+
23
+ def load_lockfile():
24
+ """Load the command state lockfile."""
25
+ if LOCKFILE_PATH.exists():
26
+ with open(LOCKFILE_PATH, "r") as f:
27
+ return json.load(f)
28
+ return []
29
+
30
+
31
+ def save_lockfile(states):
32
+ """Save states to the command state lockfile."""
33
+ LOCKFILE_PATH.parent.mkdir(parents=True, exist_ok=True)
34
+ with open(LOCKFILE_PATH, "w") as f:
35
+ json.dump(states, f, indent=2, default=str)
36
+
37
+
38
+ def append_lockfile(new_state):
39
+ """Append a new state to the lockfile."""
40
+ states = load_lockfile()
41
+ states.append(new_state)
42
+ save_lockfile(states)
43
+
44
+
45
+ def find_state_by_hash(hash_value):
46
+ """Find a state by its hash value."""
47
+ states = load_lockfile()
48
+ for state in states:
49
+ if state["hash"] == hash_value:
50
+ return state
51
+ return None
52
+
53
+
54
+ def restore_command_state(hash_value):
55
+ """Restore to a previous command state."""
56
+ state = find_state_by_hash(hash_value)
57
+ if not state:
58
+ return False
59
+ # Here you would implement logic to restore the command registry to this state
60
+ # For now, just print the commands
61
+ print(json.dumps(state["commands"], indent=2))
62
+ return True
63
+
64
+
65
+ def get_current_command_state():
66
+ """Collect all command metadata (names, groups, etc.)"""
67
+ # Import here to avoid circular imports
68
+ import importlib
69
+ import inspect
70
+ import os
71
+ from pathlib import Path
72
+
73
+ commands = []
74
+
75
+ # Look for command modules in the mcli package
76
+ mcli_path = Path(__file__).parent.parent
77
+
78
+ # This finds command groups as directories under mcli
79
+ for item in mcli_path.iterdir():
80
+ if item.is_dir() and not item.name.startswith("__") and not item.name.startswith("."):
81
+ group_name = item.name
82
+
83
+ # Recursively find all Python files that might define commands
84
+ for py_file in item.glob("**/*.py"):
85
+ if py_file.name.startswith("__"):
86
+ continue
87
+
88
+ # Convert file path to module path
89
+ relative_path = py_file.relative_to(mcli_path.parent)
90
+ module_name = str(relative_path.with_suffix("")).replace(os.sep, ".")
91
+
92
+ try:
93
+ # Try to import the module
94
+ module = importlib.import_module(module_name)
95
+
96
+ # Suppress Streamlit logging noise during command collection
97
+ if "streamlit" in module_name or "dashboard" in module_name:
98
+ # Suppress streamlit logger to avoid noise
99
+ import logging
100
+
101
+ streamlit_logger = logging.getLogger("streamlit")
102
+ original_level = streamlit_logger.level
103
+ streamlit_logger.setLevel(logging.CRITICAL)
104
+
105
+ try:
106
+ # Import and extract commands
107
+ pass
108
+ finally:
109
+ # Restore original logging level
110
+ streamlit_logger.setLevel(original_level)
111
+
112
+ # Extract command and group objects
113
+ for name, obj in inspect.getmembers(module):
114
+ # Handle Click commands and groups
115
+ if isinstance(obj, click.Command):
116
+ if isinstance(obj, click.Group):
117
+ # Found a Click group
118
+ app_info = {
119
+ "name": obj.name,
120
+ "group": group_name,
121
+ "path": module_name,
122
+ "help": obj.help,
123
+ }
124
+ commands.append(app_info)
125
+
126
+ # Add subcommands if any
127
+ for cmd_name, cmd in obj.commands.items():
128
+ commands.append(
129
+ {
130
+ "name": cmd_name,
131
+ "group": f"{group_name}.{app_info['name']}",
132
+ "path": f"{module_name}.{cmd_name}",
133
+ "help": cmd.help,
134
+ }
135
+ )
136
+ else:
137
+ # Found a standalone Click command
138
+ commands.append(
139
+ {
140
+ "name": obj.name,
141
+ "group": group_name,
142
+ "path": f"{module_name}.{obj.name}",
143
+ "help": obj.help,
144
+ }
145
+ )
146
+ except (ImportError, AttributeError) as e:
147
+ logger.debug(f"Skipping {module_name}: {e}")
148
+
149
+ return commands
150
+
151
+
152
+ def hash_command_state(commands):
153
+ """Hash the command state for fast comparison."""
154
+ # Sort for deterministic hash
155
+ commands_sorted = sorted(commands, key=lambda c: (c.get("group") or "", c["name"]))
156
+ state_json = json.dumps(commands_sorted, sort_keys=True)
157
+ return hashlib.sha256(state_json.encode("utf-8")).hexdigest()
158
+
159
+
160
+ @click.group(name="lock")
161
+ def lock():
162
+ """Manage workflow lockfile and verification."""
163
+ pass
164
+
165
+
166
+ @lock.command("list")
167
+ def list_states():
168
+ """List all saved command states (hash, timestamp, #commands)."""
169
+ states = load_lockfile()
170
+ if not states:
171
+ click.echo("No command states found.")
172
+ return
173
+
174
+ table = Table(title="Command States")
175
+ table.add_column("Hash", style="cyan")
176
+ table.add_column("Timestamp", style="green")
177
+ table.add_column("# Commands", style="yellow")
178
+
179
+ for state in states:
180
+ table.add_row(state["hash"][:8], state["timestamp"], str(len(state["commands"])))
181
+
182
+ console.print(table)
183
+
184
+
185
+ @lock.command("restore")
186
+ @click.argument("hash_value")
187
+ def restore_state(hash_value):
188
+ """Restore to a previous command state by hash."""
189
+ if restore_command_state(hash_value):
190
+ click.echo(f"Restored to state {hash_value[:8]}")
191
+ else:
192
+ click.echo(f"State {hash_value[:8]} not found.", err=True)
193
+
194
+
195
+ @lock.command("write")
196
+ @click.argument("json_file", required=False, type=click.Path(exists=False))
197
+ def write_state(json_file):
198
+ """Write a new command state to the lockfile from a JSON file or the current app state."""
199
+ import traceback
200
+
201
+ print("[DEBUG] write_state called")
202
+ print(f"[DEBUG] LOCKFILE_PATH: {LOCKFILE_PATH}")
203
+ try:
204
+ if json_file:
205
+ print(f"[DEBUG] Loading command state from file: {json_file}")
206
+ with open(json_file, "r") as f:
207
+ commands = json.load(f)
208
+ click.echo(f"Loaded command state from {json_file}.")
209
+ else:
210
+ print("[DEBUG] Snapshotting current command state.")
211
+ commands = get_current_command_state()
212
+
213
+ state_hash = hash_command_state(commands)
214
+ new_state = {
215
+ "hash": state_hash,
216
+ "timestamp": datetime.utcnow().isoformat() + "Z",
217
+ "commands": commands,
218
+ }
219
+ append_lockfile(new_state)
220
+ print(f"[DEBUG] Wrote new command state {state_hash[:8]} to lockfile at {LOCKFILE_PATH}")
221
+ click.echo(f"Wrote new command state {state_hash[:8]} to lockfile.")
222
+ except Exception as e:
223
+ print(f"[ERROR] Exception in write_state: {e}")
224
+ print(traceback.format_exc())
225
+ click.echo(f"[ERROR] Failed to write command state: {e}", err=True)
226
+
227
+
228
+ @lock.command("verify")
229
+ @click.option(
230
+ "--global", "-g", "is_global", is_flag=True, help="Verify global commands instead of local"
231
+ )
232
+ def verify_commands(is_global):
233
+ """
234
+ Verify that custom commands match the lockfile.
235
+
236
+ By default verifies local commands (if in git repo), use --global/-g for global commands.
237
+ """
238
+ manager = get_command_manager(global_mode=is_global)
239
+
240
+ # First, ensure lockfile is up to date
241
+ manager.update_lockfile()
242
+
243
+ verification = manager.verify_lockfile()
244
+
245
+ if verification["valid"]:
246
+ console.print("[green]All custom commands are in sync with the lockfile.[/green]")
247
+ return 0
248
+
249
+ console.print("[yellow]Commands are out of sync with the lockfile:[/yellow]\n")
250
+
251
+ if verification["missing"]:
252
+ console.print("Missing commands (in lockfile but not found):")
253
+ for name in verification["missing"]:
254
+ console.print(f" - {name}")
255
+
256
+ if verification["extra"]:
257
+ console.print("\nExtra commands (not in lockfile):")
258
+ for name in verification["extra"]:
259
+ console.print(f" - {name}")
260
+
261
+ if verification["modified"]:
262
+ console.print("\nModified commands:")
263
+ for name in verification["modified"]:
264
+ console.print(f" - {name}")
265
+
266
+ console.print("\n[dim]Run 'mcli lock update' to sync the lockfile[/dim]")
267
+
268
+ return 1
269
+
270
+
271
+ @lock.command("update")
272
+ @click.option(
273
+ "--global", "-g", "is_global", is_flag=True, help="Update global lockfile instead of local"
274
+ )
275
+ def update_lockfile(is_global):
276
+ """
277
+ Update the commands lockfile with current state.
278
+
279
+ By default updates local lockfile (if in git repo), use --global/-g for global lockfile.
280
+ """
281
+ manager = get_command_manager(global_mode=is_global)
282
+
283
+ if manager.update_lockfile():
284
+ console.print(f"[green]Updated lockfile: {manager.lockfile_path}[/green]")
285
+ return 0
286
+ else:
287
+ console.print("[red]Failed to update lockfile.[/red]")
288
+ return 1