mcli-framework 7.3.1__py3-none-any.whl → 7.4.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.
- mcli/app/commands_cmd.py +741 -0
- mcli/ml/dashboard/app_integrated.py +286 -37
- mcli/ml/dashboard/app_training.py +1 -1
- mcli/ml/dashboard/pages/cicd.py +1 -1
- mcli/ml/dashboard/pages/debug_dependencies.py +364 -0
- mcli/ml/dashboard/pages/gravity_viz.py +565 -0
- mcli/ml/dashboard/pages/monte_carlo_predictions.py +555 -0
- mcli/ml/dashboard/pages/overview.py +378 -0
- mcli/ml/dashboard/pages/scrapers_and_logs.py +22 -6
- mcli/ml/dashboard/pages/test_portfolio.py +54 -4
- mcli/ml/dashboard/pages/trading.py +80 -26
- mcli/ml/dashboard/streamlit_extras_utils.py +297 -0
- mcli/ml/dashboard/utils.py +7 -0
- mcli/ml/dashboard/warning_suppression.py +34 -0
- mcli/ml/database/session.py +169 -16
- mcli/ml/predictions/monte_carlo.py +428 -0
- mcli/ml/trading/__init__.py +7 -1
- mcli/ml/trading/alpaca_client.py +82 -18
- mcli/self/self_cmd.py +263 -24
- {mcli_framework-7.3.1.dist-info → mcli_framework-7.4.0.dist-info}/METADATA +3 -2
- {mcli_framework-7.3.1.dist-info → mcli_framework-7.4.0.dist-info}/RECORD +25 -18
- {mcli_framework-7.3.1.dist-info → mcli_framework-7.4.0.dist-info}/WHEEL +0 -0
- {mcli_framework-7.3.1.dist-info → mcli_framework-7.4.0.dist-info}/entry_points.txt +0 -0
- {mcli_framework-7.3.1.dist-info → mcli_framework-7.4.0.dist-info}/licenses/LICENSE +0 -0
- {mcli_framework-7.3.1.dist-info → mcli_framework-7.4.0.dist-info}/top_level.txt +0 -0
mcli/app/commands_cmd.py
CHANGED
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
import json
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import tempfile
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
2
7
|
from typing import Optional
|
|
3
8
|
|
|
4
9
|
import click
|
|
10
|
+
from rich.prompt import Prompt
|
|
5
11
|
|
|
6
12
|
from mcli.lib.api.daemon_client import get_daemon_client
|
|
13
|
+
from mcli.lib.custom_commands import get_command_manager
|
|
7
14
|
from mcli.lib.discovery.command_discovery import get_command_discovery
|
|
15
|
+
from mcli.lib.logger.logger import get_logger
|
|
8
16
|
from mcli.lib.ui.styling import console
|
|
9
17
|
|
|
18
|
+
logger = get_logger(__name__)
|
|
19
|
+
|
|
10
20
|
|
|
11
21
|
@click.group()
|
|
12
22
|
def commands():
|
|
@@ -224,3 +234,734 @@ def command_info(command_name: str, as_json: bool):
|
|
|
224
234
|
|
|
225
235
|
except Exception as e:
|
|
226
236
|
console.print(f"[red]Error: {e}[/red]")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# Custom command management functions
|
|
240
|
+
# Moved from mcli.self.self_cmd for better organization
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def get_command_template(name: str, group: Optional[str] = None) -> str:
|
|
244
|
+
"""Generate template code for a new command."""
|
|
245
|
+
if group:
|
|
246
|
+
# Template for a command in a group using Click
|
|
247
|
+
template = f'''"""
|
|
248
|
+
{name} command for mcli.{group}.
|
|
249
|
+
"""
|
|
250
|
+
import click
|
|
251
|
+
from typing import Optional, List
|
|
252
|
+
from pathlib import Path
|
|
253
|
+
from mcli.lib.logger.logger import get_logger
|
|
254
|
+
|
|
255
|
+
logger = get_logger()
|
|
256
|
+
|
|
257
|
+
# Create a Click command group
|
|
258
|
+
@click.group(name="{name}")
|
|
259
|
+
def app():
|
|
260
|
+
"""Description for {name} command group."""
|
|
261
|
+
pass
|
|
262
|
+
|
|
263
|
+
@app.command("hello")
|
|
264
|
+
@click.argument("name", default="World")
|
|
265
|
+
def hello(name: str):
|
|
266
|
+
"""Example subcommand."""
|
|
267
|
+
logger.info(f"Hello, {{name}}! This is the {name} command.")
|
|
268
|
+
click.echo(f"Hello, {{name}}! This is the {name} command.")
|
|
269
|
+
'''
|
|
270
|
+
else:
|
|
271
|
+
# Template for a command directly under workflow using Click
|
|
272
|
+
template = f'''"""
|
|
273
|
+
{name} command for mcli.
|
|
274
|
+
"""
|
|
275
|
+
import click
|
|
276
|
+
from typing import Optional, List
|
|
277
|
+
from pathlib import Path
|
|
278
|
+
from mcli.lib.logger.logger import get_logger
|
|
279
|
+
|
|
280
|
+
logger = get_logger()
|
|
281
|
+
|
|
282
|
+
def {name}_command(name: str = "World"):
|
|
283
|
+
"""
|
|
284
|
+
{name.capitalize()} command.
|
|
285
|
+
"""
|
|
286
|
+
logger.info(f"Hello, {{name}}! This is the {name} command.")
|
|
287
|
+
click.echo(f"Hello, {{name}}! This is the {name} command.")
|
|
288
|
+
'''
|
|
289
|
+
|
|
290
|
+
return template
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def open_editor_for_command(command_name: str, command_group: str, description: str) -> Optional[str]:
|
|
294
|
+
"""
|
|
295
|
+
Open the user's default editor to allow them to write command logic.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
command_name: Name of the command
|
|
299
|
+
command_group: Group for the command
|
|
300
|
+
description: Description of the command
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
The Python code written by the user, or None if cancelled
|
|
304
|
+
"""
|
|
305
|
+
import subprocess
|
|
306
|
+
import sys
|
|
307
|
+
|
|
308
|
+
# Get the user's default editor
|
|
309
|
+
editor = os.environ.get('EDITOR')
|
|
310
|
+
if not editor:
|
|
311
|
+
# Try common editors in order of preference
|
|
312
|
+
for common_editor in ['vim', 'nano', 'code', 'subl', 'atom', 'emacs']:
|
|
313
|
+
if subprocess.run(['which', common_editor], capture_output=True).returncode == 0:
|
|
314
|
+
editor = common_editor
|
|
315
|
+
break
|
|
316
|
+
|
|
317
|
+
if not editor:
|
|
318
|
+
click.echo("No editor found. Please set the EDITOR environment variable or install vim/nano.")
|
|
319
|
+
return None
|
|
320
|
+
|
|
321
|
+
# Create a temporary file with the template
|
|
322
|
+
template = get_command_template(command_name, command_group)
|
|
323
|
+
|
|
324
|
+
# Add helpful comments to the template
|
|
325
|
+
enhanced_template = f'''"""
|
|
326
|
+
{command_name} command for mcli.{command_group}.
|
|
327
|
+
|
|
328
|
+
Description: {description}
|
|
329
|
+
|
|
330
|
+
Instructions:
|
|
331
|
+
1. Write your Python command logic below
|
|
332
|
+
2. Use Click decorators for command definition
|
|
333
|
+
3. Save and close the editor to create the command
|
|
334
|
+
4. The command will be automatically converted to JSON format
|
|
335
|
+
|
|
336
|
+
Example Click command structure:
|
|
337
|
+
@click.command()
|
|
338
|
+
@click.argument('name', default='World')
|
|
339
|
+
def my_command(name):
|
|
340
|
+
\"""My custom command.\"""
|
|
341
|
+
click.echo(f"Hello, {{name}}!")
|
|
342
|
+
"""
|
|
343
|
+
import click
|
|
344
|
+
from typing import Optional, List
|
|
345
|
+
from pathlib import Path
|
|
346
|
+
from mcli.lib.logger.logger import get_logger
|
|
347
|
+
|
|
348
|
+
logger = get_logger()
|
|
349
|
+
|
|
350
|
+
# Write your command logic here:
|
|
351
|
+
# Replace this template with your actual command implementation
|
|
352
|
+
|
|
353
|
+
{template.split('"""')[2].split('"""')[0] if '"""' in template else ''}
|
|
354
|
+
|
|
355
|
+
# Your command implementation goes here:
|
|
356
|
+
# Example:
|
|
357
|
+
# @click.command()
|
|
358
|
+
# @click.argument('name', default='World')
|
|
359
|
+
# def {command_name}_command(name):
|
|
360
|
+
# \"\"\"{description}\"\"\"
|
|
361
|
+
# logger.info(f"Executing {command_name} command with name: {{name}}")
|
|
362
|
+
# click.echo(f"Hello, {{name}}! This is the {command_name} command.")
|
|
363
|
+
'''
|
|
364
|
+
|
|
365
|
+
# Create temporary file
|
|
366
|
+
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as temp_file:
|
|
367
|
+
temp_file.write(enhanced_template)
|
|
368
|
+
temp_file_path = temp_file.name
|
|
369
|
+
|
|
370
|
+
try:
|
|
371
|
+
# Check if we're in an interactive environment
|
|
372
|
+
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
|
373
|
+
click.echo("Editor requires an interactive terminal. Use --template flag for non-interactive mode.")
|
|
374
|
+
return None
|
|
375
|
+
|
|
376
|
+
# Open editor
|
|
377
|
+
click.echo(f"Opening {editor} to edit command logic...")
|
|
378
|
+
click.echo("Write your Python command logic and save the file to continue.")
|
|
379
|
+
click.echo("Press Ctrl+C to cancel command creation.")
|
|
380
|
+
|
|
381
|
+
# Run the editor
|
|
382
|
+
result = subprocess.run([editor, temp_file_path], check=False)
|
|
383
|
+
|
|
384
|
+
if result.returncode != 0:
|
|
385
|
+
click.echo("Editor exited with error. Command creation cancelled.")
|
|
386
|
+
return None
|
|
387
|
+
|
|
388
|
+
# Read the edited content
|
|
389
|
+
with open(temp_file_path, 'r') as f:
|
|
390
|
+
edited_code = f.read()
|
|
391
|
+
|
|
392
|
+
# Check if the file was actually edited (not just the template)
|
|
393
|
+
if edited_code.strip() == enhanced_template.strip():
|
|
394
|
+
click.echo("No changes detected. Command creation cancelled.")
|
|
395
|
+
return None
|
|
396
|
+
|
|
397
|
+
# Extract the actual command code (remove the instructions)
|
|
398
|
+
lines = edited_code.split('\n')
|
|
399
|
+
code_lines = []
|
|
400
|
+
in_code_section = False
|
|
401
|
+
|
|
402
|
+
for line in lines:
|
|
403
|
+
if line.strip().startswith('# Your command implementation goes here:'):
|
|
404
|
+
in_code_section = True
|
|
405
|
+
continue
|
|
406
|
+
if in_code_section:
|
|
407
|
+
code_lines.append(line)
|
|
408
|
+
|
|
409
|
+
if not code_lines or not any(line.strip() for line in code_lines):
|
|
410
|
+
# Fallback: use the entire file content
|
|
411
|
+
code_lines = lines
|
|
412
|
+
|
|
413
|
+
final_code = '\n'.join(code_lines).strip()
|
|
414
|
+
|
|
415
|
+
if not final_code:
|
|
416
|
+
click.echo("No command code found. Command creation cancelled.")
|
|
417
|
+
return None
|
|
418
|
+
|
|
419
|
+
click.echo("Command code captured successfully!")
|
|
420
|
+
return final_code
|
|
421
|
+
|
|
422
|
+
except KeyboardInterrupt:
|
|
423
|
+
click.echo("\nCommand creation cancelled by user.")
|
|
424
|
+
return None
|
|
425
|
+
except Exception as e:
|
|
426
|
+
click.echo(f"Error opening editor: {e}")
|
|
427
|
+
return None
|
|
428
|
+
finally:
|
|
429
|
+
# Clean up temporary file
|
|
430
|
+
try:
|
|
431
|
+
os.unlink(temp_file_path)
|
|
432
|
+
except OSError:
|
|
433
|
+
pass
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
@commands.command("add")
|
|
437
|
+
@click.argument("command_name", required=True)
|
|
438
|
+
@click.option("--group", "-g", help="Command group (defaults to 'workflow')", default="workflow")
|
|
439
|
+
@click.option(
|
|
440
|
+
"--description", "-d", help="Description for the command", default="Custom command"
|
|
441
|
+
)
|
|
442
|
+
@click.option(
|
|
443
|
+
"--template", "-t", is_flag=True, help="Use template mode (skip editor and use predefined template)"
|
|
444
|
+
)
|
|
445
|
+
def add_command(command_name, group, description, template):
|
|
446
|
+
"""
|
|
447
|
+
Generate a new portable custom command saved to ~/.mcli/commands/.
|
|
448
|
+
|
|
449
|
+
This command will open your default editor to allow you to write the Python logic
|
|
450
|
+
for your command. The editor will be opened with a template that you can modify.
|
|
451
|
+
|
|
452
|
+
Commands are automatically nested under the 'workflow' group by default,
|
|
453
|
+
making them portable and persistent across updates.
|
|
454
|
+
|
|
455
|
+
Example:
|
|
456
|
+
mcli commands add my_command
|
|
457
|
+
mcli commands add analytics --group data
|
|
458
|
+
mcli commands add quick_cmd --template # Use template without editor
|
|
459
|
+
"""
|
|
460
|
+
command_name = command_name.lower().replace("-", "_")
|
|
461
|
+
|
|
462
|
+
# Validate command name
|
|
463
|
+
if not re.match(r"^[a-z][a-z0-9_]*$", command_name):
|
|
464
|
+
logger.error(
|
|
465
|
+
f"Invalid command name: {command_name}. Use lowercase letters, numbers, and underscores (starting with a letter)."
|
|
466
|
+
)
|
|
467
|
+
click.echo(
|
|
468
|
+
f"Invalid command name: {command_name}. Use lowercase letters, numbers, and underscores (starting with a letter).",
|
|
469
|
+
err=True,
|
|
470
|
+
)
|
|
471
|
+
return 1
|
|
472
|
+
|
|
473
|
+
# Validate group name if provided
|
|
474
|
+
if group:
|
|
475
|
+
command_group = group.lower().replace("-", "_")
|
|
476
|
+
if not re.match(r"^[a-z][a-z0-9_]*$", command_group):
|
|
477
|
+
logger.error(
|
|
478
|
+
f"Invalid group name: {command_group}. Use lowercase letters, numbers, and underscores (starting with a letter)."
|
|
479
|
+
)
|
|
480
|
+
click.echo(
|
|
481
|
+
f"Invalid group name: {command_group}. Use lowercase letters, numbers, and underscores (starting with a letter).",
|
|
482
|
+
err=True,
|
|
483
|
+
)
|
|
484
|
+
return 1
|
|
485
|
+
else:
|
|
486
|
+
command_group = "workflow" # Default to workflow group
|
|
487
|
+
|
|
488
|
+
# Get the command manager
|
|
489
|
+
manager = get_command_manager()
|
|
490
|
+
|
|
491
|
+
# Check if command already exists
|
|
492
|
+
command_file = manager.commands_dir / f"{command_name}.json"
|
|
493
|
+
if command_file.exists():
|
|
494
|
+
logger.warning(f"Custom command already exists: {command_name}")
|
|
495
|
+
should_override = Prompt.ask(
|
|
496
|
+
"Command already exists. Override?", choices=["y", "n"], default="n"
|
|
497
|
+
)
|
|
498
|
+
if should_override.lower() != "y":
|
|
499
|
+
logger.info("Command creation aborted.")
|
|
500
|
+
click.echo("Command creation aborted.")
|
|
501
|
+
return 1
|
|
502
|
+
|
|
503
|
+
# Generate command code
|
|
504
|
+
if template:
|
|
505
|
+
# Use template mode - generate and save directly
|
|
506
|
+
code = get_command_template(command_name, command_group)
|
|
507
|
+
click.echo(f"Using template for command: {command_name}")
|
|
508
|
+
else:
|
|
509
|
+
# Editor mode - open editor for user to write code
|
|
510
|
+
click.echo(f"Opening editor for command: {command_name}")
|
|
511
|
+
code = open_editor_for_command(command_name, command_group, description)
|
|
512
|
+
if code is None:
|
|
513
|
+
click.echo("Command creation cancelled.")
|
|
514
|
+
return 1
|
|
515
|
+
|
|
516
|
+
# Save the command
|
|
517
|
+
saved_path = manager.save_command(
|
|
518
|
+
name=command_name,
|
|
519
|
+
code=code,
|
|
520
|
+
description=description,
|
|
521
|
+
group=command_group,
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
logger.info(f"Created portable custom command: {command_name}")
|
|
525
|
+
console.print(f"[green]Created portable custom command: {command_name}[/green]")
|
|
526
|
+
console.print(f"[dim]Saved to: {saved_path}[/dim]")
|
|
527
|
+
console.print("[dim]Command will be automatically loaded on next mcli startup[/dim]")
|
|
528
|
+
console.print(
|
|
529
|
+
f"[dim]You can share this command by copying {saved_path} to another machine's ~/.mcli/commands/ directory[/dim]"
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
return 0
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
@commands.command("list-custom")
|
|
536
|
+
def list_custom_commands():
|
|
537
|
+
"""
|
|
538
|
+
List all custom commands stored in ~/.mcli/commands/.
|
|
539
|
+
"""
|
|
540
|
+
from rich.table import Table
|
|
541
|
+
|
|
542
|
+
manager = get_command_manager()
|
|
543
|
+
cmds = manager.load_all_commands()
|
|
544
|
+
|
|
545
|
+
if not cmds:
|
|
546
|
+
console.print("No custom commands found.")
|
|
547
|
+
console.print("Create one with: mcli commands add <name>")
|
|
548
|
+
return 0
|
|
549
|
+
|
|
550
|
+
table = Table(title="Custom Commands")
|
|
551
|
+
table.add_column("Name", style="green")
|
|
552
|
+
table.add_column("Group", style="blue")
|
|
553
|
+
table.add_column("Description", style="yellow")
|
|
554
|
+
table.add_column("Version", style="cyan")
|
|
555
|
+
table.add_column("Updated", style="dim")
|
|
556
|
+
|
|
557
|
+
for cmd in cmds:
|
|
558
|
+
table.add_row(
|
|
559
|
+
cmd["name"],
|
|
560
|
+
cmd.get("group", "-"),
|
|
561
|
+
cmd.get("description", ""),
|
|
562
|
+
cmd.get("version", "1.0"),
|
|
563
|
+
cmd.get("updated_at", "")[:10] if cmd.get("updated_at") else "-",
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
console.print(table)
|
|
567
|
+
console.print(f"\n[dim]Commands directory: {manager.commands_dir}[/dim]")
|
|
568
|
+
console.print(f"[dim]Lockfile: {manager.lockfile_path}[/dim]")
|
|
569
|
+
|
|
570
|
+
return 0
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
@commands.command("remove")
|
|
574
|
+
@click.argument("command_name", required=True)
|
|
575
|
+
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
|
|
576
|
+
def remove_command(command_name, yes):
|
|
577
|
+
"""
|
|
578
|
+
Remove a custom command from ~/.mcli/commands/.
|
|
579
|
+
"""
|
|
580
|
+
manager = get_command_manager()
|
|
581
|
+
command_file = manager.commands_dir / f"{command_name}.json"
|
|
582
|
+
|
|
583
|
+
if not command_file.exists():
|
|
584
|
+
console.print(f"[red]Command '{command_name}' not found.[/red]")
|
|
585
|
+
return 1
|
|
586
|
+
|
|
587
|
+
if not yes:
|
|
588
|
+
should_delete = Prompt.ask(
|
|
589
|
+
f"Delete command '{command_name}'?", choices=["y", "n"], default="n"
|
|
590
|
+
)
|
|
591
|
+
if should_delete.lower() != "y":
|
|
592
|
+
console.print("Deletion cancelled.")
|
|
593
|
+
return 0
|
|
594
|
+
|
|
595
|
+
if manager.delete_command(command_name):
|
|
596
|
+
console.print(f"[green]Deleted custom command: {command_name}[/green]")
|
|
597
|
+
return 0
|
|
598
|
+
else:
|
|
599
|
+
console.print(f"[red]Failed to delete command: {command_name}[/red]")
|
|
600
|
+
return 1
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
@commands.command("export")
|
|
604
|
+
@click.argument("export_file", type=click.Path(), required=False)
|
|
605
|
+
def export_commands(export_file):
|
|
606
|
+
"""
|
|
607
|
+
Export all custom commands to a JSON file.
|
|
608
|
+
|
|
609
|
+
If no file is specified, exports to commands-export.json in current directory.
|
|
610
|
+
"""
|
|
611
|
+
manager = get_command_manager()
|
|
612
|
+
|
|
613
|
+
if not export_file:
|
|
614
|
+
export_file = "commands-export.json"
|
|
615
|
+
|
|
616
|
+
export_path = Path(export_file)
|
|
617
|
+
|
|
618
|
+
if manager.export_commands(export_path):
|
|
619
|
+
console.print(f"[green]Exported custom commands to: {export_path}[/green]")
|
|
620
|
+
console.print(
|
|
621
|
+
f"[dim]Import on another machine with: mcli commands import {export_path}[/dim]"
|
|
622
|
+
)
|
|
623
|
+
return 0
|
|
624
|
+
else:
|
|
625
|
+
console.print("[red]Failed to export commands.[/red]")
|
|
626
|
+
return 1
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
@commands.command("import")
|
|
630
|
+
@click.argument("import_file", type=click.Path(exists=True), required=True)
|
|
631
|
+
@click.option("--overwrite", is_flag=True, help="Overwrite existing commands")
|
|
632
|
+
def import_commands(import_file, overwrite):
|
|
633
|
+
"""
|
|
634
|
+
Import custom commands from a JSON file.
|
|
635
|
+
"""
|
|
636
|
+
manager = get_command_manager()
|
|
637
|
+
import_path = Path(import_file)
|
|
638
|
+
|
|
639
|
+
results = manager.import_commands(import_path, overwrite=overwrite)
|
|
640
|
+
|
|
641
|
+
success_count = sum(1 for v in results.values() if v)
|
|
642
|
+
failed_count = len(results) - success_count
|
|
643
|
+
|
|
644
|
+
if success_count > 0:
|
|
645
|
+
console.print(f"[green]Imported {success_count} command(s)[/green]")
|
|
646
|
+
|
|
647
|
+
if failed_count > 0:
|
|
648
|
+
console.print(
|
|
649
|
+
f"[yellow]Skipped {failed_count} command(s) (already exist, use --overwrite to replace)[/yellow]"
|
|
650
|
+
)
|
|
651
|
+
console.print("Skipped commands:")
|
|
652
|
+
for name, success in results.items():
|
|
653
|
+
if not success:
|
|
654
|
+
console.print(f" - {name}")
|
|
655
|
+
|
|
656
|
+
return 0
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
@commands.command("verify")
|
|
660
|
+
def verify_commands():
|
|
661
|
+
"""
|
|
662
|
+
Verify that custom commands match the lockfile.
|
|
663
|
+
"""
|
|
664
|
+
manager = get_command_manager()
|
|
665
|
+
|
|
666
|
+
# First, ensure lockfile is up to date
|
|
667
|
+
manager.update_lockfile()
|
|
668
|
+
|
|
669
|
+
verification = manager.verify_lockfile()
|
|
670
|
+
|
|
671
|
+
if verification["valid"]:
|
|
672
|
+
console.print("[green]All custom commands are in sync with the lockfile.[/green]")
|
|
673
|
+
return 0
|
|
674
|
+
|
|
675
|
+
console.print("[yellow]Commands are out of sync with the lockfile:[/yellow]\n")
|
|
676
|
+
|
|
677
|
+
if verification["missing"]:
|
|
678
|
+
console.print("Missing commands (in lockfile but not found):")
|
|
679
|
+
for name in verification["missing"]:
|
|
680
|
+
console.print(f" - {name}")
|
|
681
|
+
|
|
682
|
+
if verification["extra"]:
|
|
683
|
+
console.print("\nExtra commands (not in lockfile):")
|
|
684
|
+
for name in verification["extra"]:
|
|
685
|
+
console.print(f" - {name}")
|
|
686
|
+
|
|
687
|
+
if verification["modified"]:
|
|
688
|
+
console.print("\nModified commands:")
|
|
689
|
+
for name in verification["modified"]:
|
|
690
|
+
console.print(f" - {name}")
|
|
691
|
+
|
|
692
|
+
console.print("\n[dim]Run 'mcli commands update-lockfile' to sync the lockfile[/dim]")
|
|
693
|
+
|
|
694
|
+
return 1
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
@commands.command("update-lockfile")
|
|
698
|
+
def update_lockfile():
|
|
699
|
+
"""
|
|
700
|
+
Update the commands lockfile with current state.
|
|
701
|
+
"""
|
|
702
|
+
manager = get_command_manager()
|
|
703
|
+
|
|
704
|
+
if manager.update_lockfile():
|
|
705
|
+
console.print(f"[green]Updated lockfile: {manager.lockfile_path}[/green]")
|
|
706
|
+
return 0
|
|
707
|
+
else:
|
|
708
|
+
console.print("[red]Failed to update lockfile.[/red]")
|
|
709
|
+
return 1
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
@commands.command("edit")
|
|
713
|
+
@click.argument("command_name")
|
|
714
|
+
@click.option("--editor", "-e", help="Editor to use (defaults to $EDITOR)")
|
|
715
|
+
def edit_command(command_name, editor):
|
|
716
|
+
"""
|
|
717
|
+
Edit a command interactively using $EDITOR.
|
|
718
|
+
|
|
719
|
+
Opens the command's Python code in your preferred editor,
|
|
720
|
+
allows you to make changes, and saves the updated version.
|
|
721
|
+
|
|
722
|
+
Examples:
|
|
723
|
+
mcli commands edit my-command
|
|
724
|
+
mcli commands edit my-command --editor code
|
|
725
|
+
"""
|
|
726
|
+
import subprocess
|
|
727
|
+
|
|
728
|
+
manager = get_command_manager()
|
|
729
|
+
|
|
730
|
+
# Load the command
|
|
731
|
+
command_file = manager.commands_dir / f"{command_name}.json"
|
|
732
|
+
if not command_file.exists():
|
|
733
|
+
console.print(f"[red]Command not found: {command_name}[/red]")
|
|
734
|
+
return 1
|
|
735
|
+
|
|
736
|
+
try:
|
|
737
|
+
with open(command_file, 'r') as f:
|
|
738
|
+
command_data = json.load(f)
|
|
739
|
+
except Exception as e:
|
|
740
|
+
console.print(f"[red]Failed to load command: {e}[/red]")
|
|
741
|
+
return 1
|
|
742
|
+
|
|
743
|
+
code = command_data.get('code', '')
|
|
744
|
+
|
|
745
|
+
if not code:
|
|
746
|
+
console.print(f"[red]Command has no code: {command_name}[/red]")
|
|
747
|
+
return 1
|
|
748
|
+
|
|
749
|
+
# Determine editor
|
|
750
|
+
if not editor:
|
|
751
|
+
editor = os.environ.get('EDITOR', 'vim')
|
|
752
|
+
|
|
753
|
+
console.print(f"Opening command in {editor}...")
|
|
754
|
+
|
|
755
|
+
# Create temp file with the code
|
|
756
|
+
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False,
|
|
757
|
+
prefix=f"{command_name}_") as tmp:
|
|
758
|
+
tmp.write(code)
|
|
759
|
+
tmp_path = tmp.name
|
|
760
|
+
|
|
761
|
+
try:
|
|
762
|
+
# Open in editor
|
|
763
|
+
result = subprocess.run([editor, tmp_path])
|
|
764
|
+
|
|
765
|
+
if result.returncode != 0:
|
|
766
|
+
console.print(f"[yellow]Editor exited with code {result.returncode}[/yellow]")
|
|
767
|
+
|
|
768
|
+
# Read edited content
|
|
769
|
+
with open(tmp_path, 'r') as f:
|
|
770
|
+
new_code = f.read()
|
|
771
|
+
|
|
772
|
+
# Check if code changed
|
|
773
|
+
if new_code.strip() == code.strip():
|
|
774
|
+
console.print("No changes made")
|
|
775
|
+
return 0
|
|
776
|
+
|
|
777
|
+
# Validate syntax
|
|
778
|
+
try:
|
|
779
|
+
compile(new_code, '<string>', 'exec')
|
|
780
|
+
except SyntaxError as e:
|
|
781
|
+
console.print(f"[red]Syntax error in edited code: {e}[/red]")
|
|
782
|
+
should_save = Prompt.ask(
|
|
783
|
+
"Save anyway?", choices=["y", "n"], default="n"
|
|
784
|
+
)
|
|
785
|
+
if should_save.lower() != "y":
|
|
786
|
+
return 1
|
|
787
|
+
|
|
788
|
+
# Update the command
|
|
789
|
+
command_data['code'] = new_code
|
|
790
|
+
command_data['updated_at'] = datetime.now().isoformat()
|
|
791
|
+
|
|
792
|
+
with open(command_file, 'w') as f:
|
|
793
|
+
json.dump(command_data, f, indent=2)
|
|
794
|
+
|
|
795
|
+
# Update lockfile
|
|
796
|
+
manager.update_lockfile()
|
|
797
|
+
|
|
798
|
+
console.print(f"[green]Updated command: {command_name}[/green]")
|
|
799
|
+
console.print(f"[dim]Saved to: {command_file}[/dim]")
|
|
800
|
+
console.print("[dim]Reload with: mcli self reload or restart mcli[/dim]")
|
|
801
|
+
|
|
802
|
+
finally:
|
|
803
|
+
Path(tmp_path).unlink(missing_ok=True)
|
|
804
|
+
|
|
805
|
+
return 0
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
@commands.command("import-script")
|
|
809
|
+
@click.argument("script_path", type=click.Path(exists=True))
|
|
810
|
+
@click.option("--name", "-n", help="Command name (defaults to script filename)")
|
|
811
|
+
@click.option("--group", "-g", default="workflow", help="Command group")
|
|
812
|
+
@click.option("--description", "-d", help="Command description")
|
|
813
|
+
@click.option("--interactive", "-i", is_flag=True, help="Open in $EDITOR for review/editing")
|
|
814
|
+
def import_script(script_path, name, group, description, interactive):
|
|
815
|
+
"""
|
|
816
|
+
Import a Python script as a portable JSON command.
|
|
817
|
+
|
|
818
|
+
Converts a Python script into a JSON command that can be loaded
|
|
819
|
+
by mcli. The script should define Click commands.
|
|
820
|
+
|
|
821
|
+
Examples:
|
|
822
|
+
mcli commands import-script my_script.py
|
|
823
|
+
mcli commands import-script my_script.py --name custom-cmd --interactive
|
|
824
|
+
"""
|
|
825
|
+
import subprocess
|
|
826
|
+
|
|
827
|
+
script_file = Path(script_path).resolve()
|
|
828
|
+
|
|
829
|
+
if not script_file.exists():
|
|
830
|
+
console.print(f"[red]Script not found: {script_file}[/red]")
|
|
831
|
+
return 1
|
|
832
|
+
|
|
833
|
+
# Read the script content
|
|
834
|
+
try:
|
|
835
|
+
with open(script_file, 'r') as f:
|
|
836
|
+
code = f.read()
|
|
837
|
+
except Exception as e:
|
|
838
|
+
console.print(f"[red]Failed to read script: {e}[/red]")
|
|
839
|
+
return 1
|
|
840
|
+
|
|
841
|
+
# Determine command name
|
|
842
|
+
if not name:
|
|
843
|
+
name = script_file.stem.lower().replace("-", "_")
|
|
844
|
+
|
|
845
|
+
# Validate command name
|
|
846
|
+
if not re.match(r"^[a-z][a-z0-9_]*$", name):
|
|
847
|
+
console.print(f"[red]Invalid command name: {name}[/red]")
|
|
848
|
+
return 1
|
|
849
|
+
|
|
850
|
+
# Interactive editing
|
|
851
|
+
if interactive:
|
|
852
|
+
editor = os.environ.get('EDITOR', 'vim')
|
|
853
|
+
console.print(f"Opening in {editor} for review...")
|
|
854
|
+
|
|
855
|
+
# Create temp file with the code
|
|
856
|
+
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as tmp:
|
|
857
|
+
tmp.write(code)
|
|
858
|
+
tmp_path = tmp.name
|
|
859
|
+
|
|
860
|
+
try:
|
|
861
|
+
subprocess.run([editor, tmp_path], check=True)
|
|
862
|
+
|
|
863
|
+
# Read edited content
|
|
864
|
+
with open(tmp_path, 'r') as f:
|
|
865
|
+
code = f.read()
|
|
866
|
+
finally:
|
|
867
|
+
Path(tmp_path).unlink(missing_ok=True)
|
|
868
|
+
|
|
869
|
+
# Get description
|
|
870
|
+
if not description:
|
|
871
|
+
# Try to extract from docstring
|
|
872
|
+
import ast
|
|
873
|
+
try:
|
|
874
|
+
tree = ast.parse(code)
|
|
875
|
+
description = ast.get_docstring(tree) or f"Imported from {script_file.name}"
|
|
876
|
+
except:
|
|
877
|
+
description = f"Imported from {script_file.name}"
|
|
878
|
+
|
|
879
|
+
# Save as JSON command
|
|
880
|
+
manager = get_command_manager()
|
|
881
|
+
|
|
882
|
+
saved_path = manager.save_command(
|
|
883
|
+
name=name,
|
|
884
|
+
code=code,
|
|
885
|
+
description=description,
|
|
886
|
+
group=group,
|
|
887
|
+
metadata={
|
|
888
|
+
"source": "import-script",
|
|
889
|
+
"original_file": str(script_file),
|
|
890
|
+
"imported_at": datetime.now().isoformat()
|
|
891
|
+
}
|
|
892
|
+
)
|
|
893
|
+
|
|
894
|
+
console.print(f"[green]Imported script as command: {name}[/green]")
|
|
895
|
+
console.print(f"[dim]Saved to: {saved_path}[/dim]")
|
|
896
|
+
console.print(f"[dim]Use with: mcli {group} {name}[/dim]")
|
|
897
|
+
console.print("[dim]Command will be available after restart or reload[/dim]")
|
|
898
|
+
|
|
899
|
+
return 0
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
@commands.command("export-script")
|
|
903
|
+
@click.argument("command_name")
|
|
904
|
+
@click.option("--output", "-o", type=click.Path(), help="Output file path")
|
|
905
|
+
@click.option("--standalone", "-s", is_flag=True, help="Make script standalone (add if __name__ == '__main__')")
|
|
906
|
+
def export_script(command_name, output, standalone):
|
|
907
|
+
"""
|
|
908
|
+
Export a JSON command to a Python script.
|
|
909
|
+
|
|
910
|
+
Converts a portable JSON command back to a standalone Python script
|
|
911
|
+
that can be edited and run independently.
|
|
912
|
+
|
|
913
|
+
Examples:
|
|
914
|
+
mcli commands export-script my-command
|
|
915
|
+
mcli commands export-script my-command --output my_script.py --standalone
|
|
916
|
+
"""
|
|
917
|
+
manager = get_command_manager()
|
|
918
|
+
|
|
919
|
+
# Load the command
|
|
920
|
+
command_file = manager.commands_dir / f"{command_name}.json"
|
|
921
|
+
if not command_file.exists():
|
|
922
|
+
console.print(f"[red]Command not found: {command_name}[/red]")
|
|
923
|
+
return 1
|
|
924
|
+
|
|
925
|
+
try:
|
|
926
|
+
with open(command_file, 'r') as f:
|
|
927
|
+
command_data = json.load(f)
|
|
928
|
+
except Exception as e:
|
|
929
|
+
console.print(f"[red]Failed to load command: {e}[/red]")
|
|
930
|
+
return 1
|
|
931
|
+
|
|
932
|
+
# Get the code
|
|
933
|
+
code = command_data.get('code', '')
|
|
934
|
+
|
|
935
|
+
if not code:
|
|
936
|
+
console.print(f"[red]Command has no code: {command_name}[/red]")
|
|
937
|
+
return 1
|
|
938
|
+
|
|
939
|
+
# Add standalone wrapper if requested
|
|
940
|
+
if standalone:
|
|
941
|
+
# Check if already has if __name__ == '__main__'
|
|
942
|
+
if "if __name__" not in code:
|
|
943
|
+
code += "\n\nif __name__ == '__main__':\n app()\n"
|
|
944
|
+
|
|
945
|
+
# Determine output path
|
|
946
|
+
if not output:
|
|
947
|
+
output = f"{command_name}.py"
|
|
948
|
+
|
|
949
|
+
output_file = Path(output)
|
|
950
|
+
|
|
951
|
+
# Write the script
|
|
952
|
+
try:
|
|
953
|
+
with open(output_file, 'w') as f:
|
|
954
|
+
f.write(code)
|
|
955
|
+
except Exception as e:
|
|
956
|
+
console.print(f"[red]Failed to write script: {e}[/red]")
|
|
957
|
+
return 1
|
|
958
|
+
|
|
959
|
+
console.print(f"[green]Exported command to script: {output_file}[/green]")
|
|
960
|
+
console.print(f"[dim]Source command: {command_name}[/dim]")
|
|
961
|
+
|
|
962
|
+
if standalone:
|
|
963
|
+
console.print(f"[dim]Run standalone with: python {output_file}[/dim]")
|
|
964
|
+
|
|
965
|
+
console.print(f"[dim]Edit and re-import with: mcli commands import-script {output_file}[/dim]")
|
|
966
|
+
|
|
967
|
+
return 0
|