mcli-framework 7.1.3__py3-none-any.whl → 7.3.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 (114) hide show
  1. mcli/__init__.py +160 -0
  2. mcli/__main__.py +14 -0
  3. mcli/app/__init__.py +23 -0
  4. mcli/app/main.py +10 -0
  5. mcli/app/model/__init__.py +0 -0
  6. mcli/app/video/__init__.py +5 -0
  7. mcli/chat/__init__.py +34 -0
  8. mcli/lib/__init__.py +0 -0
  9. mcli/lib/api/__init__.py +0 -0
  10. mcli/lib/auth/__init__.py +1 -0
  11. mcli/lib/config/__init__.py +1 -0
  12. mcli/lib/custom_commands.py +424 -0
  13. mcli/lib/erd/__init__.py +25 -0
  14. mcli/lib/files/__init__.py +0 -0
  15. mcli/lib/fs/__init__.py +1 -0
  16. mcli/lib/logger/__init__.py +3 -0
  17. mcli/lib/paths.py +12 -0
  18. mcli/lib/performance/__init__.py +17 -0
  19. mcli/lib/pickles/__init__.py +1 -0
  20. mcli/lib/shell/__init__.py +0 -0
  21. mcli/lib/toml/__init__.py +1 -0
  22. mcli/lib/watcher/__init__.py +0 -0
  23. mcli/ml/__init__.py +16 -0
  24. mcli/ml/api/__init__.py +30 -0
  25. mcli/ml/api/routers/__init__.py +27 -0
  26. mcli/ml/api/schemas.py +2 -2
  27. mcli/ml/auth/__init__.py +45 -0
  28. mcli/ml/auth/models.py +2 -2
  29. mcli/ml/backtesting/__init__.py +39 -0
  30. mcli/ml/cli/__init__.py +5 -0
  31. mcli/ml/cli/main.py +1 -1
  32. mcli/ml/config/__init__.py +33 -0
  33. mcli/ml/configs/__init__.py +16 -0
  34. mcli/ml/dashboard/__init__.py +12 -0
  35. mcli/ml/dashboard/app.py +13 -13
  36. mcli/ml/dashboard/app_integrated.py +1309 -148
  37. mcli/ml/dashboard/app_supabase.py +46 -21
  38. mcli/ml/dashboard/app_training.py +14 -14
  39. mcli/ml/dashboard/components/__init__.py +7 -0
  40. mcli/ml/dashboard/components/charts.py +258 -0
  41. mcli/ml/dashboard/components/metrics.py +125 -0
  42. mcli/ml/dashboard/components/tables.py +228 -0
  43. mcli/ml/dashboard/pages/__init__.py +6 -0
  44. mcli/ml/dashboard/pages/cicd.py +382 -0
  45. mcli/ml/dashboard/pages/predictions_enhanced.py +834 -0
  46. mcli/ml/dashboard/pages/scrapers_and_logs.py +1060 -0
  47. mcli/ml/dashboard/pages/test_portfolio.py +373 -0
  48. mcli/ml/dashboard/pages/trading.py +714 -0
  49. mcli/ml/dashboard/pages/workflows.py +533 -0
  50. mcli/ml/dashboard/utils.py +154 -0
  51. mcli/ml/data_ingestion/__init__.py +39 -0
  52. mcli/ml/database/__init__.py +47 -0
  53. mcli/ml/experimentation/__init__.py +29 -0
  54. mcli/ml/features/__init__.py +39 -0
  55. mcli/ml/mlops/__init__.py +33 -0
  56. mcli/ml/models/__init__.py +94 -0
  57. mcli/ml/monitoring/__init__.py +25 -0
  58. mcli/ml/optimization/__init__.py +27 -0
  59. mcli/ml/predictions/__init__.py +5 -0
  60. mcli/ml/preprocessing/__init__.py +28 -0
  61. mcli/ml/scripts/__init__.py +1 -0
  62. mcli/ml/trading/__init__.py +60 -0
  63. mcli/ml/trading/alpaca_client.py +353 -0
  64. mcli/ml/trading/migrations.py +164 -0
  65. mcli/ml/trading/models.py +418 -0
  66. mcli/ml/trading/paper_trading.py +326 -0
  67. mcli/ml/trading/risk_management.py +370 -0
  68. mcli/ml/trading/trading_service.py +480 -0
  69. mcli/ml/training/__init__.py +10 -0
  70. mcli/ml/training/train_model.py +569 -0
  71. mcli/mygroup/__init__.py +3 -0
  72. mcli/public/__init__.py +1 -0
  73. mcli/public/commands/__init__.py +2 -0
  74. mcli/self/__init__.py +3 -0
  75. mcli/self/self_cmd.py +579 -91
  76. mcli/workflow/__init__.py +0 -0
  77. mcli/workflow/daemon/__init__.py +15 -0
  78. mcli/workflow/daemon/daemon.py +21 -3
  79. mcli/workflow/dashboard/__init__.py +5 -0
  80. mcli/workflow/docker/__init__.py +0 -0
  81. mcli/workflow/file/__init__.py +0 -0
  82. mcli/workflow/gcloud/__init__.py +1 -0
  83. mcli/workflow/git_commit/__init__.py +0 -0
  84. mcli/workflow/interview/__init__.py +0 -0
  85. mcli/workflow/politician_trading/__init__.py +4 -0
  86. mcli/workflow/politician_trading/data_sources.py +259 -1
  87. mcli/workflow/politician_trading/models.py +159 -1
  88. mcli/workflow/politician_trading/scrapers_corporate_registry.py +846 -0
  89. mcli/workflow/politician_trading/scrapers_free_sources.py +516 -0
  90. mcli/workflow/politician_trading/scrapers_third_party.py +391 -0
  91. mcli/workflow/politician_trading/seed_database.py +539 -0
  92. mcli/workflow/registry/__init__.py +0 -0
  93. mcli/workflow/repo/__init__.py +0 -0
  94. mcli/workflow/scheduler/__init__.py +25 -0
  95. mcli/workflow/search/__init__.py +0 -0
  96. mcli/workflow/sync/__init__.py +5 -0
  97. mcli/workflow/videos/__init__.py +1 -0
  98. mcli/workflow/wakatime/__init__.py +80 -0
  99. mcli/workflow/workflow.py +8 -27
  100. {mcli_framework-7.1.3.dist-info → mcli_framework-7.3.1.dist-info}/METADATA +3 -1
  101. {mcli_framework-7.1.3.dist-info → mcli_framework-7.3.1.dist-info}/RECORD +105 -29
  102. mcli/workflow/daemon/api_daemon.py +0 -800
  103. mcli/workflow/daemon/commands.py +0 -1196
  104. mcli/workflow/dashboard/dashboard_cmd.py +0 -120
  105. mcli/workflow/file/file.py +0 -100
  106. mcli/workflow/git_commit/commands.py +0 -430
  107. mcli/workflow/politician_trading/commands.py +0 -1939
  108. mcli/workflow/scheduler/commands.py +0 -493
  109. mcli/workflow/sync/sync_cmd.py +0 -437
  110. mcli/workflow/videos/videos.py +0 -242
  111. {mcli_framework-7.1.3.dist-info → mcli_framework-7.3.1.dist-info}/WHEEL +0 -0
  112. {mcli_framework-7.1.3.dist-info → mcli_framework-7.3.1.dist-info}/entry_points.txt +0 -0
  113. {mcli_framework-7.1.3.dist-info → mcli_framework-7.3.1.dist-info}/licenses/LICENSE +0 -0
  114. {mcli_framework-7.1.3.dist-info → mcli_framework-7.3.1.dist-info}/top_level.txt +0 -0
mcli/self/self_cmd.py CHANGED
@@ -30,6 +30,7 @@ except ImportError:
30
30
  process = None
31
31
 
32
32
  from mcli.lib.logger.logger import get_logger
33
+ from mcli.lib.custom_commands import get_command_manager
33
34
 
34
35
  logger = get_logger()
35
36
 
@@ -202,6 +203,7 @@ def get_command_template(name: str, group: Optional[str] = None) -> str:
202
203
 
203
204
  if group:
204
205
  # Template for a command in a group using Click
206
+ # Use 'app' as the variable name so it's found first
205
207
  template = f'''"""
206
208
  {name} command for mcli.{group}.
207
209
  """
@@ -214,11 +216,11 @@ logger = get_logger()
214
216
 
215
217
  # Create a Click command group
216
218
  @click.group(name="{name}")
217
- def {name}_group():
219
+ def app():
218
220
  """Description for {name} command group."""
219
221
  pass
220
222
 
221
- @{name}_group.command("hello")
223
+ @app.command("hello")
222
224
  @click.argument("name", default="World")
223
225
  def hello(name: str):
224
226
  """Example subcommand."""
@@ -387,14 +389,20 @@ def collect_commands() -> List[Dict[str, Any]]:
387
389
 
388
390
  @self_app.command("add-command")
389
391
  @click.argument("command_name", required=True)
390
- @click.option("--group", "-g", help="Optional command group to create under")
391
- def add_command(command_name, group):
392
+ @click.option("--group", "-g", help="Command group (defaults to 'workflow')", default="workflow")
393
+ @click.option(
394
+ "--description", "-d", help="Description for the command", default="Custom command"
395
+ )
396
+ def add_command(command_name, group, description):
392
397
  """
393
- Generate a new command template that can be used by mcli.
398
+ Generate a new portable custom command saved to ~/.mcli/commands/.
399
+
400
+ Commands are automatically nested under the 'workflow' group by default,
401
+ making them portable and persistent across updates.
394
402
 
395
403
  Example:
396
- mcli self add my_command
397
- mcli self add feature_command --group features
404
+ mcli self add-command my_command
405
+ mcli self add-command analytics --group data
398
406
  """
399
407
  command_name = command_name.lower().replace("-", "_")
400
408
 
@@ -409,13 +417,9 @@ def add_command(command_name, group):
409
417
  )
410
418
  return 1
411
419
 
412
- mcli_path = Path(__file__).parent.parent
413
-
420
+ # Validate group name if provided
414
421
  if group:
415
- # Creating under a specific group
416
422
  command_group = group.lower().replace("-", "_")
417
-
418
- # Validate group name
419
423
  if not re.match(r"^[a-z][a-z0-9_]*$", command_group):
420
424
  logger.error(
421
425
  f"Invalid group name: {command_group}. Use lowercase letters, numbers, and underscores (starting with a letter)."
@@ -425,101 +429,325 @@ def add_command(command_name, group):
425
429
  err=True,
426
430
  )
427
431
  return 1
432
+ else:
433
+ command_group = "workflow" # Default to workflow group
428
434
 
429
- # Check if group exists, create if needed
430
- group_path = mcli_path / command_group
431
- if not group_path.exists():
432
- # Create group directory and __init__.py
433
- group_path.mkdir(parents=True, exist_ok=True)
434
- with open(group_path / "__init__.py", "w") as f:
435
- f.write(f'"""\n{command_group.capitalize()} commands for mcli.\n"""')
436
- logger.info(f"Created new command group directory: {command_group}")
437
- click.echo(f"Created new command group directory: {command_group}")
438
-
439
- # Create command file
440
- command_file_path = group_path / f"{command_name}.py"
441
- if command_file_path.exists():
442
- logger.warning(f"Command file already exists: {command_file_path}")
443
- should_override = Prompt.ask(
444
- "File already exists. Override?", choices=["y", "n"], default="n"
445
- )
446
- if should_override.lower() != "y":
447
- logger.info("Command creation aborted.")
448
- click.echo("Command creation aborted.")
449
- return 1
435
+ # Get the command manager
436
+ manager = get_command_manager()
437
+
438
+ # Check if command already exists
439
+ command_file = manager.commands_dir / f"{command_name}.json"
440
+ if command_file.exists():
441
+ logger.warning(f"Custom command already exists: {command_name}")
442
+ should_override = Prompt.ask(
443
+ "Command already exists. Override?", choices=["y", "n"], default="n"
444
+ )
445
+ if should_override.lower() != "y":
446
+ logger.info("Command creation aborted.")
447
+ click.echo("Command creation aborted.")
448
+ return 1
449
+
450
+ # Generate command code
451
+ code = get_command_template(command_name, command_group)
452
+
453
+ # Save the command
454
+ saved_path = manager.save_command(
455
+ name=command_name,
456
+ code=code,
457
+ description=description,
458
+ group=command_group,
459
+ )
460
+
461
+ logger.info(f"Created portable custom command: {command_name}")
462
+ click.echo(f"✅ Created portable custom command: {command_name}")
463
+ click.echo(f"📁 Saved to: {saved_path}")
464
+ click.echo(f"🔄 Command will be automatically loaded on next mcli startup")
465
+ click.echo(
466
+ f"💡 You can share this command by copying {saved_path} to another machine's ~/.mcli/commands/ directory"
467
+ )
468
+
469
+ return 0
470
+
471
+
472
+ @self_app.command("list-commands")
473
+ def list_commands():
474
+ """
475
+ List all custom commands stored in ~/.mcli/commands/.
476
+ """
477
+ manager = get_command_manager()
478
+ commands = manager.load_all_commands()
479
+
480
+ if not commands:
481
+ click.echo("No custom commands found.")
482
+ click.echo(
483
+ f"Create one with: mcli self add-command <name>"
484
+ )
485
+ return 0
486
+
487
+ table = Table(title="Custom Commands")
488
+ table.add_column("Name", style="green")
489
+ table.add_column("Group", style="blue")
490
+ table.add_column("Description", style="yellow")
491
+ table.add_column("Version", style="cyan")
492
+ table.add_column("Updated", style="dim")
493
+
494
+ for cmd in commands:
495
+ table.add_row(
496
+ cmd["name"],
497
+ cmd.get("group", "-"),
498
+ cmd.get("description", ""),
499
+ cmd.get("version", "1.0"),
500
+ cmd.get("updated_at", "")[:10] if cmd.get("updated_at") else "-",
501
+ )
502
+
503
+ console.print(table)
504
+ click.echo(f"\n📁 Commands directory: {manager.commands_dir}")
505
+ click.echo(f"🔒 Lockfile: {manager.lockfile_path}")
506
+
507
+ return 0
508
+
509
+
510
+ @self_app.command("remove-command")
511
+ @click.argument("command_name", required=True)
512
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
513
+ def remove_command(command_name, yes):
514
+ """
515
+ Remove a custom command from ~/.mcli/commands/.
516
+ """
517
+ manager = get_command_manager()
518
+ command_file = manager.commands_dir / f"{command_name}.json"
519
+
520
+ if not command_file.exists():
521
+ click.echo(f"❌ Command '{command_name}' not found.", err=True)
522
+ return 1
450
523
 
451
- # Generate command file
452
- with open(command_file_path, "w") as f:
453
- f.write(get_command_template(command_name, command_group))
524
+ if not yes:
525
+ should_delete = Prompt.ask(
526
+ f"Delete command '{command_name}'?", choices=["y", "n"], default="n"
527
+ )
528
+ if should_delete.lower() != "y":
529
+ click.echo("Deletion cancelled.")
530
+ return 0
454
531
 
455
- logger.info(f"Created new command: {command_name} in group: {command_group}")
456
- click.echo(f"Created new command: {command_name} in group: {command_group}")
457
- click.echo(f"File created: {command_file_path}")
532
+ if manager.delete_command(command_name):
533
+ click.echo(f" Deleted custom command: {command_name}")
534
+ return 0
535
+ else:
536
+ click.echo(f"❌ Failed to delete command: {command_name}", err=True)
537
+ return 1
538
+
539
+
540
+ @self_app.command("export-commands")
541
+ @click.argument("export_file", type=click.Path(), required=False)
542
+ def export_commands(export_file):
543
+ """
544
+ Export all custom commands to a JSON file.
545
+
546
+ If no file is specified, exports to commands-export.json in current directory.
547
+ """
548
+ manager = get_command_manager()
549
+
550
+ if not export_file:
551
+ export_file = "commands-export.json"
552
+
553
+ export_path = Path(export_file)
554
+
555
+ if manager.export_commands(export_path):
556
+ click.echo(f"✅ Exported custom commands to: {export_path}")
458
557
  click.echo(
459
- f"To use this command, add 'from mcli.{command_group}.{command_name} import {command_name}_group' to your main imports"
558
+ f"💡 Import on another machine with: mcli self import-commands {export_path}"
460
559
  )
560
+ return 0
561
+ else:
562
+ click.echo("❌ Failed to export commands.", err=True)
563
+ return 1
564
+
565
+
566
+ @self_app.command("import-commands")
567
+ @click.argument("import_file", type=click.Path(exists=True), required=True)
568
+ @click.option("--overwrite", is_flag=True, help="Overwrite existing commands")
569
+ def import_commands(import_file, overwrite):
570
+ """
571
+ Import custom commands from a JSON file.
572
+ """
573
+ manager = get_command_manager()
574
+ import_path = Path(import_file)
575
+
576
+ results = manager.import_commands(import_path, overwrite=overwrite)
577
+
578
+ success_count = sum(1 for v in results.values() if v)
579
+ failed_count = len(results) - success_count
580
+
581
+ if success_count > 0:
582
+ click.echo(f"✅ Imported {success_count} command(s)")
583
+
584
+ if failed_count > 0:
461
585
  click.echo(
462
- f"Then add '{command_name}_group to your main CLI group using app.add_command({command_name}_group)'"
586
+ f"⚠️ Skipped {failed_count} command(s) (already exist, use --overwrite to replace)"
463
587
  )
588
+ click.echo("Skipped commands:")
589
+ for name, success in results.items():
590
+ if not success:
591
+ click.echo(f" - {name}")
592
+
593
+ return 0
594
+
595
+
596
+ @self_app.command("verify-commands")
597
+ def verify_commands():
598
+ """
599
+ Verify that custom commands match the lockfile.
600
+ """
601
+ manager = get_command_manager()
602
+
603
+ # First, ensure lockfile is up to date
604
+ manager.update_lockfile()
605
+
606
+ verification = manager.verify_lockfile()
607
+
608
+ if verification["valid"]:
609
+ click.echo("✅ All custom commands are in sync with the lockfile.")
610
+ return 0
611
+
612
+ click.echo("⚠️ Commands are out of sync with the lockfile:\n")
613
+
614
+ if verification["missing"]:
615
+ click.echo(f"Missing commands (in lockfile but not found):")
616
+ for name in verification["missing"]:
617
+ click.echo(f" - {name}")
618
+
619
+ if verification["extra"]:
620
+ click.echo(f"\nExtra commands (not in lockfile):")
621
+ for name in verification["extra"]:
622
+ click.echo(f" - {name}")
623
+
624
+ if verification["modified"]:
625
+ click.echo(f"\nModified commands:")
626
+ for name in verification["modified"]:
627
+ click.echo(f" - {name}")
628
+
629
+ click.echo(f"\n💡 Run 'mcli self update-lockfile' to sync the lockfile")
630
+
631
+ return 1
632
+
633
+
634
+ @self_app.command("update-lockfile")
635
+ def update_lockfile():
636
+ """
637
+ Update the commands lockfile with current state.
638
+ """
639
+ manager = get_command_manager()
464
640
 
641
+ if manager.update_lockfile():
642
+ click.echo(f"✅ Updated lockfile: {manager.lockfile_path}")
643
+ return 0
465
644
  else:
466
- # Creating directly under self
467
- command_file_path = mcli_path / "self" / f"{command_name}.py"
645
+ click.echo("❌ Failed to update lockfile.", err=True)
646
+ return 1
468
647
 
469
- if command_file_path.exists():
470
- logger.warning(f"Command file already exists: {command_file_path}")
471
- should_override = Prompt.ask(
472
- "File already exists. Override?", choices=["y", "n"], default="n"
473
- )
474
- if should_override.lower() != "y":
475
- logger.info("Command creation aborted.")
476
- click.echo("Command creation aborted.")
477
- return 1
478
648
 
479
- # Generate command file
480
- with open(command_file_path, "w") as f:
481
- f.write(get_command_template(command_name))
482
-
483
- # Update self_cmd.py to import and register the new command
484
- with open(Path(__file__), "r") as f:
485
- content = f.read()
486
-
487
- # Add import statement if not exists
488
- import_statement = f"from mcli.self.{command_name} import {command_name}_command"
489
- if import_statement not in content:
490
- import_section_end = content.find("logger = get_logger()")
491
- if import_section_end != -1:
492
- updated_content = (
493
- content[:import_section_end]
494
- + import_statement
495
- + "\n"
496
- + content[import_section_end:]
497
- )
649
+ @self_app.command("extract-workflow-commands")
650
+ @click.option(
651
+ "--output", "-o", type=click.Path(), help="Output file (default: workflow-commands.json)"
652
+ )
653
+ def extract_workflow_commands(output):
654
+ """
655
+ Extract workflow commands from Python modules to JSON format.
498
656
 
499
- # Add command registration (Click syntax)
500
- registration = f"@self_app.command('{command_name}')\ndef {command_name}(name=\"World\"):\n return {command_name}_command(name)\n"
501
- registration_point = updated_content.rfind("def ")
502
- if registration_point != -1:
503
- # Find the end of the last function
504
- last_func_end = updated_content.find("\n\n", registration_point)
505
- if last_func_end != -1:
506
- updated_content = (
507
- updated_content[: last_func_end + 2]
508
- + registration
509
- + updated_content[last_func_end + 2 :]
510
- )
657
+ This command helps migrate existing workflow commands to portable JSON format.
658
+ """
659
+ import inspect
660
+ from pathlib import Path
661
+
662
+ output_file = Path(output) if output else Path("workflow-commands.json")
663
+
664
+ workflow_commands = []
665
+
666
+ # Try to get workflow from the main app
667
+ try:
668
+ from mcli.app.main import create_app
669
+
670
+ app = create_app()
671
+
672
+ # Check if workflow group exists
673
+ if "workflow" in app.commands:
674
+ workflow_group = app.commands["workflow"]
675
+
676
+ # Force load lazy group if needed
677
+ if hasattr(workflow_group, "_load_group"):
678
+ workflow_group = workflow_group._load_group()
679
+
680
+ if hasattr(workflow_group, "commands"):
681
+ for cmd_name, cmd_obj in workflow_group.commands.items():
682
+ # Extract command information
683
+ command_info = {
684
+ "name": cmd_name,
685
+ "group": "workflow",
686
+ "description": cmd_obj.help or "Workflow command",
687
+ "version": "1.0",
688
+ "metadata": {"source": "workflow", "migrated": True},
689
+ }
690
+
691
+ # Create a template based on command type
692
+ # Replace hyphens with underscores for valid Python function names
693
+ safe_name = cmd_name.replace("-", "_")
694
+
695
+ if isinstance(cmd_obj, click.Group):
696
+ # For groups, create a template
697
+ command_info["code"] = f'''"""
698
+ {cmd_name} workflow command.
699
+ """
700
+ import click
701
+
702
+ @click.group(name="{cmd_name}")
703
+ def app():
704
+ """{cmd_obj.help or 'Workflow command group'}"""
705
+ pass
706
+
707
+ # Add your subcommands here
708
+ '''
511
709
  else:
512
- updated_content += "\n\n" + registration
710
+ # For regular commands, create a template
711
+ command_info["code"] = f'''"""
712
+ {cmd_name} workflow command.
713
+ """
714
+ import click
513
715
 
514
- with open(Path(__file__), "w") as f:
515
- f.write(updated_content)
716
+ @click.command(name="{cmd_name}")
717
+ def app():
718
+ """{cmd_obj.help or 'Workflow command'}"""
719
+ click.echo("Workflow command: {cmd_name}")
720
+ # Add your implementation here
721
+ '''
516
722
 
517
- logger.info(f"Created new command: {command_name} in self module")
518
- click.echo(f"Created new command: {command_name} in self module")
519
- click.echo(f"File created: {command_file_path}")
520
- click.echo(f"Command has been automatically registered with self_app")
723
+ workflow_commands.append(command_info)
521
724
 
522
- return 0
725
+ if workflow_commands:
726
+ import json
727
+
728
+ with open(output_file, "w") as f:
729
+ json.dump(workflow_commands, f, indent=2)
730
+
731
+ click.echo(f"✅ Extracted {len(workflow_commands)} workflow commands")
732
+ click.echo(f"📁 Saved to: {output_file}")
733
+ click.echo(
734
+ f"\n💡 These are templates. Import with: mcli self import-commands {output_file}"
735
+ )
736
+ click.echo(
737
+ " Then customize the code in ~/.mcli/commands/<command>.json"
738
+ )
739
+ return 0
740
+ else:
741
+ click.echo("⚠️ No workflow commands found to extract")
742
+ return 1
743
+
744
+ except Exception as e:
745
+ logger.error(f"Failed to extract workflow commands: {e}")
746
+ click.echo(f"❌ Failed to extract workflow commands: {e}", err=True)
747
+ import traceback
748
+
749
+ click.echo(traceback.format_exc(), err=True)
750
+ return 1
523
751
 
524
752
 
525
753
  @click.group("plugin")
@@ -1329,6 +1557,266 @@ def update(check: bool, pre: bool, yes: bool, skip_ci_check: bool):
1329
1557
  console.print(f"[dim]{traceback.format_exc()}[/dim]")
1330
1558
 
1331
1559
 
1560
+ @self_app.command("import-script")
1561
+ @click.argument("script_path", type=click.Path(exists=True))
1562
+ @click.option("--name", "-n", help="Command name (defaults to script filename)")
1563
+ @click.option("--group", "-g", default="workflow", help="Command group")
1564
+ @click.option("--description", "-d", help="Command description")
1565
+ @click.option("--interactive", "-i", is_flag=True, help="Open in $EDITOR for review/editing")
1566
+ def import_script(script_path, name, group, description, interactive):
1567
+ """
1568
+ Import a Python script as a portable JSON command.
1569
+
1570
+ Converts a Python script into a JSON command that can be loaded
1571
+ by mcli. The script should define Click commands.
1572
+
1573
+ Examples:
1574
+ mcli self import-script my_script.py
1575
+ mcli self import-script my_script.py --name custom-cmd --interactive
1576
+ """
1577
+ import subprocess
1578
+ import tempfile
1579
+
1580
+ script_file = Path(script_path).resolve()
1581
+
1582
+ if not script_file.exists():
1583
+ click.echo(f"❌ Script not found: {script_file}", err=True)
1584
+ return 1
1585
+
1586
+ # Read the script content
1587
+ try:
1588
+ with open(script_file, 'r') as f:
1589
+ code = f.read()
1590
+ except Exception as e:
1591
+ click.echo(f"❌ Failed to read script: {e}", err=True)
1592
+ return 1
1593
+
1594
+ # Determine command name
1595
+ if not name:
1596
+ name = script_file.stem.lower().replace("-", "_")
1597
+
1598
+ # Validate command name
1599
+ if not re.match(r"^[a-z][a-z0-9_]*$", name):
1600
+ click.echo(f"❌ Invalid command name: {name}", err=True)
1601
+ return 1
1602
+
1603
+ # Interactive editing
1604
+ if interactive:
1605
+ editor = os.environ.get('EDITOR', 'vim')
1606
+ click.echo(f"📝 Opening in {editor} for review...")
1607
+
1608
+ # Create temp file with the code
1609
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as tmp:
1610
+ tmp.write(code)
1611
+ tmp_path = tmp.name
1612
+
1613
+ try:
1614
+ subprocess.run([editor, tmp_path], check=True)
1615
+
1616
+ # Read edited content
1617
+ with open(tmp_path, 'r') as f:
1618
+ code = f.read()
1619
+ finally:
1620
+ Path(tmp_path).unlink(missing_ok=True)
1621
+
1622
+ # Get description
1623
+ if not description:
1624
+ # Try to extract from docstring
1625
+ import ast
1626
+ try:
1627
+ tree = ast.parse(code)
1628
+ description = ast.get_docstring(tree) or f"Imported from {script_file.name}"
1629
+ except:
1630
+ description = f"Imported from {script_file.name}"
1631
+
1632
+ # Save as JSON command
1633
+ manager = get_command_manager()
1634
+
1635
+ saved_path = manager.save_command(
1636
+ name=name,
1637
+ code=code,
1638
+ description=description,
1639
+ group=group,
1640
+ metadata={
1641
+ "source": "import-script",
1642
+ "original_file": str(script_file),
1643
+ "imported_at": datetime.now().isoformat()
1644
+ }
1645
+ )
1646
+
1647
+ click.echo(f"✅ Imported script as command: {name}")
1648
+ click.echo(f"📁 Saved to: {saved_path}")
1649
+ click.echo(f"🔄 Use with: mcli {group} {name}")
1650
+ click.echo(f"💡 Command will be available after restart or reload")
1651
+
1652
+ return 0
1653
+
1654
+
1655
+ @self_app.command("export-script")
1656
+ @click.argument("command_name")
1657
+ @click.option("--output", "-o", type=click.Path(), help="Output file path")
1658
+ @click.option("--standalone", "-s", is_flag=True, help="Make script standalone (add if __name__ == '__main__')")
1659
+ def export_script(command_name, output, standalone):
1660
+ """
1661
+ Export a JSON command to a Python script.
1662
+
1663
+ Converts a portable JSON command back to a standalone Python script
1664
+ that can be edited and run independently.
1665
+
1666
+ Examples:
1667
+ mcli self export-script my-command
1668
+ mcli self export-script my-command --output my_script.py --standalone
1669
+ """
1670
+ manager = get_command_manager()
1671
+
1672
+ # Load the command
1673
+ command_file = manager.commands_dir / f"{command_name}.json"
1674
+ if not command_file.exists():
1675
+ click.echo(f"❌ Command not found: {command_name}", err=True)
1676
+ return 1
1677
+
1678
+ try:
1679
+ with open(command_file, 'r') as f:
1680
+ command_data = json.load(f)
1681
+ except Exception as e:
1682
+ click.echo(f"❌ Failed to load command: {e}", err=True)
1683
+ return 1
1684
+
1685
+ # Get the code
1686
+ code = command_data.get('code', '')
1687
+
1688
+ if not code:
1689
+ click.echo(f"❌ Command has no code: {command_name}", err=True)
1690
+ return 1
1691
+
1692
+ # Add standalone wrapper if requested
1693
+ if standalone:
1694
+ # Check if already has if __name__ == '__main__'
1695
+ if "if __name__" not in code:
1696
+ code += "\n\nif __name__ == '__main__':\n app()\n"
1697
+
1698
+ # Determine output path
1699
+ if not output:
1700
+ output = f"{command_name}.py"
1701
+
1702
+ output_file = Path(output)
1703
+
1704
+ # Write the script
1705
+ try:
1706
+ with open(output_file, 'w') as f:
1707
+ f.write(code)
1708
+ except Exception as e:
1709
+ click.echo(f"❌ Failed to write script: {e}", err=True)
1710
+ return 1
1711
+
1712
+ click.echo(f"✅ Exported command to script: {output_file}")
1713
+ click.echo(f"📝 Source command: {command_name}")
1714
+
1715
+ if standalone:
1716
+ click.echo(f"🚀 Run standalone with: python {output_file}")
1717
+
1718
+ click.echo(f"💡 Edit and re-import with: mcli self import-script {output_file}")
1719
+
1720
+ return 0
1721
+
1722
+
1723
+ @self_app.command("edit-command")
1724
+ @click.argument("command_name")
1725
+ @click.option("--editor", "-e", help="Editor to use (defaults to $EDITOR)")
1726
+ def edit_command(command_name, editor):
1727
+ """
1728
+ Edit a command interactively using $EDITOR.
1729
+
1730
+ Opens the command's Python code in your preferred editor,
1731
+ allows you to make changes, and saves the updated version.
1732
+
1733
+ Examples:
1734
+ mcli self edit-command my-command
1735
+ mcli self edit-command my-command --editor code
1736
+ """
1737
+ import subprocess
1738
+ import tempfile
1739
+
1740
+ manager = get_command_manager()
1741
+
1742
+ # Load the command
1743
+ command_file = manager.commands_dir / f"{command_name}.json"
1744
+ if not command_file.exists():
1745
+ click.echo(f"❌ Command not found: {command_name}", err=True)
1746
+ return 1
1747
+
1748
+ try:
1749
+ with open(command_file, 'r') as f:
1750
+ command_data = json.load(f)
1751
+ except Exception as e:
1752
+ click.echo(f"❌ Failed to load command: {e}", err=True)
1753
+ return 1
1754
+
1755
+ code = command_data.get('code', '')
1756
+
1757
+ if not code:
1758
+ click.echo(f"❌ Command has no code: {command_name}", err=True)
1759
+ return 1
1760
+
1761
+ # Determine editor
1762
+ if not editor:
1763
+ editor = os.environ.get('EDITOR', 'vim')
1764
+
1765
+ click.echo(f"📝 Opening command in {editor}...")
1766
+
1767
+ # Create temp file with the code
1768
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False,
1769
+ prefix=f"{command_name}_") as tmp:
1770
+ tmp.write(code)
1771
+ tmp_path = tmp.name
1772
+
1773
+ try:
1774
+ # Open in editor
1775
+ result = subprocess.run([editor, tmp_path])
1776
+
1777
+ if result.returncode != 0:
1778
+ click.echo(f"⚠️ Editor exited with code {result.returncode}")
1779
+
1780
+ # Read edited content
1781
+ with open(tmp_path, 'r') as f:
1782
+ new_code = f.read()
1783
+
1784
+ # Check if code changed
1785
+ if new_code.strip() == code.strip():
1786
+ click.echo("ℹ️ No changes made")
1787
+ return 0
1788
+
1789
+ # Validate syntax
1790
+ try:
1791
+ compile(new_code, '<string>', 'exec')
1792
+ except SyntaxError as e:
1793
+ click.echo(f"❌ Syntax error in edited code: {e}", err=True)
1794
+ should_save = Prompt.ask(
1795
+ "Save anyway?", choices=["y", "n"], default="n"
1796
+ )
1797
+ if should_save.lower() != "y":
1798
+ return 1
1799
+
1800
+ # Update the command
1801
+ command_data['code'] = new_code
1802
+ command_data['updated_at'] = datetime.now().isoformat()
1803
+
1804
+ with open(command_file, 'w') as f:
1805
+ json.dump(command_data, f, indent=2)
1806
+
1807
+ # Update lockfile
1808
+ manager.generate_lockfile()
1809
+
1810
+ click.echo(f"✅ Updated command: {command_name}")
1811
+ click.echo(f"📁 Saved to: {command_file}")
1812
+ click.echo(f"🔄 Reload with: mcli self reload" or "restart mcli")
1813
+
1814
+ finally:
1815
+ Path(tmp_path).unlink(missing_ok=True)
1816
+
1817
+ return 0
1818
+
1819
+
1332
1820
  # Register the plugin group with self_app
1333
1821
  self_app.add_command(plugin)
1334
1822