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 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