mcli-framework 7.8.3__py3-none-any.whl → 7.8.5__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 (81) 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 +942 -199
  5. mcli/app/main.py +5 -21
  6. mcli/app/model/__init__.py +0 -0
  7. mcli/app/model_cmd.py +57 -472
  8. mcli/app/video/__init__.py +5 -0
  9. mcli/chat/__init__.py +34 -0
  10. mcli/lib/__init__.py +0 -0
  11. mcli/lib/api/__init__.py +0 -0
  12. mcli/lib/auth/__init__.py +1 -0
  13. mcli/lib/config/__init__.py +1 -0
  14. mcli/lib/erd/__init__.py +25 -0
  15. mcli/lib/files/__init__.py +0 -0
  16. mcli/lib/fs/__init__.py +1 -0
  17. mcli/lib/logger/__init__.py +3 -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/auth/__init__.py +41 -0
  27. mcli/ml/backtesting/__init__.py +33 -0
  28. mcli/ml/cli/__init__.py +5 -0
  29. mcli/ml/config/__init__.py +33 -0
  30. mcli/ml/configs/__init__.py +16 -0
  31. mcli/ml/dashboard/__init__.py +12 -0
  32. mcli/ml/dashboard/app_supabase.py +57 -12
  33. mcli/ml/dashboard/components/__init__.py +7 -0
  34. mcli/ml/dashboard/pages/__init__.py +6 -0
  35. mcli/ml/dashboard/pages/predictions_enhanced.py +82 -38
  36. mcli/ml/dashboard/utils.py +39 -11
  37. mcli/ml/data_ingestion/__init__.py +29 -0
  38. mcli/ml/database/__init__.py +40 -0
  39. mcli/ml/experimentation/__init__.py +29 -0
  40. mcli/ml/features/__init__.py +39 -0
  41. mcli/ml/mlops/__init__.py +19 -0
  42. mcli/ml/models/__init__.py +90 -0
  43. mcli/ml/monitoring/__init__.py +25 -0
  44. mcli/ml/optimization/__init__.py +27 -0
  45. mcli/ml/predictions/__init__.py +5 -0
  46. mcli/ml/preprocessing/__init__.py +24 -0
  47. mcli/ml/scripts/__init__.py +1 -0
  48. mcli/ml/trading/__init__.py +63 -0
  49. mcli/ml/training/__init__.py +7 -0
  50. mcli/mygroup/__init__.py +3 -0
  51. mcli/public/__init__.py +1 -0
  52. mcli/public/commands/__init__.py +2 -0
  53. mcli/self/__init__.py +3 -0
  54. mcli/self/self_cmd.py +4 -253
  55. mcli/self/store_cmd.py +5 -3
  56. mcli/workflow/__init__.py +0 -0
  57. mcli/workflow/daemon/__init__.py +15 -0
  58. mcli/workflow/dashboard/__init__.py +5 -0
  59. mcli/workflow/dashboard/dashboard_cmd.py +1 -0
  60. mcli/workflow/docker/__init__.py +0 -0
  61. mcli/workflow/file/__init__.py +0 -0
  62. mcli/workflow/gcloud/__init__.py +1 -0
  63. mcli/workflow/git_commit/__init__.py +0 -0
  64. mcli/workflow/interview/__init__.py +0 -0
  65. mcli/workflow/politician_trading/__init__.py +4 -0
  66. mcli/workflow/registry/__init__.py +0 -0
  67. mcli/workflow/repo/__init__.py +0 -0
  68. mcli/workflow/scheduler/__init__.py +25 -0
  69. mcli/workflow/search/__init__.py +0 -0
  70. mcli/workflow/sync/__init__.py +5 -0
  71. mcli/workflow/videos/__init__.py +1 -0
  72. mcli/workflow/wakatime/__init__.py +80 -0
  73. {mcli_framework-7.8.3.dist-info → mcli_framework-7.8.5.dist-info}/METADATA +1 -1
  74. {mcli_framework-7.8.3.dist-info → mcli_framework-7.8.5.dist-info}/RECORD +78 -18
  75. mcli/app/chat_cmd.py +0 -42
  76. mcli/test/cron_test_cmd.py +0 -697
  77. mcli/test/test_cmd.py +0 -30
  78. {mcli_framework-7.8.3.dist-info → mcli_framework-7.8.5.dist-info}/WHEEL +0 -0
  79. {mcli_framework-7.8.3.dist-info → mcli_framework-7.8.5.dist-info}/entry_points.txt +0 -0
  80. {mcli_framework-7.8.3.dist-info → mcli_framework-7.8.5.dist-info}/licenses/LICENSE +0 -0
  81. {mcli_framework-7.8.3.dist-info → mcli_framework-7.8.5.dist-info}/top_level.txt +0 -0
mcli/app/commands_cmd.py CHANGED
@@ -1,22 +1,176 @@
1
+ import hashlib
2
+ import importlib
3
+ import inspect
1
4
  import json
2
5
  import os
3
6
  import re
7
+ import shutil
8
+ import subprocess
4
9
  import tempfile
5
10
  from datetime import datetime
6
11
  from pathlib import Path
7
- from typing import Optional
12
+ from typing import Any, Dict, List, Optional
8
13
 
9
14
  import click
15
+ import tomli
16
+ from rich.console import Console
10
17
  from rich.prompt import Prompt
18
+ from rich.table import Table
11
19
 
12
20
  from mcli.lib.api.daemon_client import get_daemon_client
13
21
  from mcli.lib.custom_commands import get_command_manager
14
22
  from mcli.lib.discovery.command_discovery import get_command_discovery
15
23
  from mcli.lib.logger.logger import get_logger
16
- from mcli.lib.ui.styling import console
24
+ from mcli.lib.ui.styling import console, error, info, success, warning
17
25
 
18
26
  logger = get_logger(__name__)
19
27
 
28
+ # Command state lockfile configuration
29
+ LOCKFILE_PATH = Path.home() / ".local" / "mcli" / "command_lock.json"
30
+
31
+ # Command store configuration
32
+ DEFAULT_STORE_PATH = Path.home() / "repos" / "mcli-commands"
33
+ COMMANDS_PATH = Path.home() / ".mcli" / "commands"
34
+
35
+
36
+ # Helper functions for command state management
37
+
38
+
39
+ def collect_commands() -> List[Dict[str, Any]]:
40
+ """Collect all commands from the mcli application."""
41
+ commands = []
42
+
43
+ # Look for command modules in the mcli package
44
+ mcli_path = Path(__file__).parent.parent
45
+
46
+ # This finds command groups as directories under mcli
47
+ for item in mcli_path.iterdir():
48
+ if item.is_dir() and not item.name.startswith("__") and not item.name.startswith("."):
49
+ group_name = item.name
50
+
51
+ # Recursively find all Python files that might define commands
52
+ for py_file in item.glob("**/*.py"):
53
+ if py_file.name.startswith("__"):
54
+ continue
55
+
56
+ # Convert file path to module path
57
+ relative_path = py_file.relative_to(mcli_path.parent)
58
+ module_name = str(relative_path.with_suffix("")).replace(os.sep, ".")
59
+
60
+ try:
61
+ # Try to import the module
62
+ module = importlib.import_module(module_name)
63
+
64
+ # Suppress Streamlit logging noise during command collection
65
+ if "streamlit" in module_name or "dashboard" in module_name:
66
+ # Suppress streamlit logger to avoid noise
67
+ import logging
68
+
69
+ streamlit_logger = logging.getLogger("streamlit")
70
+ original_level = streamlit_logger.level
71
+ streamlit_logger.setLevel(logging.CRITICAL)
72
+
73
+ try:
74
+ # Import and extract commands
75
+ pass
76
+ finally:
77
+ # Restore original logging level
78
+ streamlit_logger.setLevel(original_level)
79
+
80
+ # Extract command and group objects
81
+ for name, obj in inspect.getmembers(module):
82
+ # Handle Click commands and groups
83
+ if isinstance(obj, click.Command):
84
+ if isinstance(obj, click.Group):
85
+ # Found a Click group
86
+ app_info = {
87
+ "name": obj.name,
88
+ "group": group_name,
89
+ "path": module_name,
90
+ "help": obj.help,
91
+ }
92
+ commands.append(app_info)
93
+
94
+ # Add subcommands if any
95
+ for cmd_name, cmd in obj.commands.items():
96
+ commands.append(
97
+ {
98
+ "name": cmd_name,
99
+ "group": f"{group_name}.{app_info['name']}",
100
+ "path": f"{module_name}.{cmd_name}",
101
+ "help": cmd.help,
102
+ }
103
+ )
104
+ else:
105
+ # Found a standalone Click command
106
+ commands.append(
107
+ {
108
+ "name": obj.name,
109
+ "group": group_name,
110
+ "path": f"{module_name}.{obj.name}",
111
+ "help": obj.help,
112
+ }
113
+ )
114
+ except (ImportError, AttributeError) as e:
115
+ logger.debug(f"Skipping {module_name}: {e}")
116
+
117
+ return commands
118
+
119
+
120
+ def get_current_command_state():
121
+ """Collect all command metadata (names, groups, etc.)"""
122
+ return collect_commands()
123
+
124
+
125
+ def hash_command_state(commands):
126
+ """Hash the command state for fast comparison."""
127
+ # Sort for deterministic hash
128
+ commands_sorted = sorted(commands, key=lambda c: (c.get("group") or "", c["name"]))
129
+ state_json = json.dumps(commands_sorted, sort_keys=True)
130
+ return hashlib.sha256(state_json.encode("utf-8")).hexdigest()
131
+
132
+
133
+ def load_lockfile():
134
+ """Load the command state lockfile."""
135
+ if LOCKFILE_PATH.exists():
136
+ with open(LOCKFILE_PATH, "r") as f:
137
+ return json.load(f)
138
+ return []
139
+
140
+
141
+ def save_lockfile(states):
142
+ """Save states to the command state lockfile."""
143
+ LOCKFILE_PATH.parent.mkdir(parents=True, exist_ok=True)
144
+ with open(LOCKFILE_PATH, "w") as f:
145
+ json.dump(states, f, indent=2, default=str)
146
+
147
+
148
+ def append_lockfile(new_state):
149
+ """Append a new state to the lockfile."""
150
+ states = load_lockfile()
151
+ states.append(new_state)
152
+ save_lockfile(states)
153
+
154
+
155
+ def find_state_by_hash(hash_value):
156
+ """Find a state by its hash value."""
157
+ states = load_lockfile()
158
+ for state in states:
159
+ if state["hash"] == hash_value:
160
+ return state
161
+ return None
162
+
163
+
164
+ def restore_command_state(hash_value):
165
+ """Restore to a previous command state."""
166
+ state = find_state_by_hash(hash_value)
167
+ if not state:
168
+ return False
169
+ # Here you would implement logic to restore the command registry to this state
170
+ # For now, just print the commands
171
+ print(json.dumps(state["commands"], indent=2))
172
+ return True
173
+
20
174
 
21
175
  @click.group()
22
176
  def commands():
@@ -27,11 +181,63 @@ def commands():
27
181
  @commands.command("list")
28
182
  @click.option("--include-groups", is_flag=True, help="Include command groups in listing")
29
183
  @click.option("--daemon-only", is_flag=True, help="Show only daemon database commands")
184
+ @click.option(
185
+ "--custom-only", is_flag=True, help="Show only custom commands from ~/.mcli/commands/"
186
+ )
30
187
  @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
31
- def list_commands(include_groups: bool, daemon_only: bool, as_json: bool):
32
- """List all available commands"""
188
+ def list_commands(include_groups: bool, daemon_only: bool, custom_only: bool, as_json: bool):
189
+ """
190
+ List all available commands.
191
+
192
+ By default, shows all discovered Click commands. Use flags to filter:
193
+ - --custom-only: Show only custom commands from ~/.mcli/commands/
194
+ - --daemon-only: Show only daemon database commands
195
+
196
+ Examples:
197
+ mcli commands list # Show all commands
198
+ mcli commands list --custom-only # Show only custom commands
199
+ mcli commands list --json # Output as JSON
200
+ """
201
+ from rich.table import Table
202
+
33
203
  try:
34
- if daemon_only:
204
+ if custom_only:
205
+ # Show only custom commands from ~/.mcli/commands/
206
+ manager = get_command_manager()
207
+ cmds = manager.load_all_commands()
208
+
209
+ if not cmds:
210
+ console.print("No custom commands found.")
211
+ console.print("Create one with: mcli commands add <name>")
212
+ return 0
213
+
214
+ if as_json:
215
+ click.echo(json.dumps({"commands": cmds, "total": len(cmds)}, indent=2))
216
+ return 0
217
+
218
+ table = Table(title="Custom Commands")
219
+ table.add_column("Name", style="green")
220
+ table.add_column("Group", style="blue")
221
+ table.add_column("Description", style="yellow")
222
+ table.add_column("Version", style="cyan")
223
+ table.add_column("Updated", style="dim")
224
+
225
+ for cmd in cmds:
226
+ table.add_row(
227
+ cmd["name"],
228
+ cmd.get("group", "-"),
229
+ cmd.get("description", ""),
230
+ cmd.get("version", "1.0"),
231
+ cmd.get("updated_at", "")[:10] if cmd.get("updated_at") else "-",
232
+ )
233
+
234
+ console.print(table)
235
+ console.print(f"\n[dim]Commands directory: {manager.commands_dir}[/dim]")
236
+ console.print(f"[dim]Lockfile: {manager.lockfile_path}[/dim]")
237
+
238
+ return 0
239
+
240
+ elif daemon_only:
35
241
  # Show only daemon database commands
36
242
  client = get_daemon_client()
37
243
  result = client.list_commands(all=True)
@@ -539,44 +745,6 @@ def add_command(command_name, group, description, template):
539
745
  return 0
540
746
 
541
747
 
542
- @commands.command("list-custom")
543
- def list_custom_commands():
544
- """
545
- List all custom commands stored in ~/.mcli/commands/.
546
- """
547
- from rich.table import Table
548
-
549
- manager = get_command_manager()
550
- cmds = manager.load_all_commands()
551
-
552
- if not cmds:
553
- console.print("No custom commands found.")
554
- console.print("Create one with: mcli commands add <name>")
555
- return 0
556
-
557
- table = Table(title="Custom Commands")
558
- table.add_column("Name", style="green")
559
- table.add_column("Group", style="blue")
560
- table.add_column("Description", style="yellow")
561
- table.add_column("Version", style="cyan")
562
- table.add_column("Updated", style="dim")
563
-
564
- for cmd in cmds:
565
- table.add_row(
566
- cmd["name"],
567
- cmd.get("group", "-"),
568
- cmd.get("description", ""),
569
- cmd.get("version", "1.0"),
570
- cmd.get("updated_at", "")[:10] if cmd.get("updated_at") else "-",
571
- )
572
-
573
- console.print(table)
574
- console.print(f"\n[dim]Commands directory: {manager.commands_dir}[/dim]")
575
- console.print(f"[dim]Lockfile: {manager.lockfile_path}[/dim]")
576
-
577
- return 0
578
-
579
-
580
748
  @commands.command("remove")
581
749
  @click.argument("command_name", required=True)
582
750
  @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
@@ -608,59 +776,224 @@ def remove_command(command_name, yes):
608
776
 
609
777
 
610
778
  @commands.command("export")
611
- @click.argument("export_file", type=click.Path(), required=False)
612
- def export_commands(export_file):
779
+ @click.argument("target", type=click.Path(), required=False)
780
+ @click.option(
781
+ "--script", "-s", is_flag=True, help="Export as Python script (requires command name)"
782
+ )
783
+ @click.option(
784
+ "--standalone", is_flag=True, help="Make script standalone (add if __name__ == '__main__')"
785
+ )
786
+ @click.option("--output", "-o", type=click.Path(), help="Output file path")
787
+ def export_commands(target, script, standalone, output):
613
788
  """
614
- Export all custom commands to a JSON file.
789
+ Export custom commands to JSON file or export a single command to Python script.
790
+
791
+ Default behavior (no flags): Export all commands to JSON
792
+ With --script/-s: Export a single command to Python script
615
793
 
616
- If no file is specified, exports to commands-export.json in current directory.
794
+ Examples:
795
+ mcli commands export # Export all to commands-export.json
796
+ mcli commands export my-export.json # Export all to specified file
797
+ mcli commands export my-cmd -s # Export command to my-cmd.py
798
+ mcli commands export my-cmd -s -o out.py --standalone
617
799
  """
618
800
  manager = get_command_manager()
619
801
 
620
- if not export_file:
621
- export_file = "commands-export.json"
802
+ if script:
803
+ # Export single command to Python script
804
+ if not target:
805
+ console.print("[red]Command name required when using --script/-s flag[/red]")
806
+ return 1
622
807
 
623
- export_path = Path(export_file)
808
+ command_name = target
809
+
810
+ # Load the command
811
+ command_file = manager.commands_dir / f"{command_name}.json"
812
+ if not command_file.exists():
813
+ console.print(f"[red]Command not found: {command_name}[/red]")
814
+ return 1
815
+
816
+ try:
817
+ with open(command_file, "r") as f:
818
+ command_data = json.load(f)
819
+ except Exception as e:
820
+ console.print(f"[red]Failed to load command: {e}[/red]")
821
+ return 1
822
+
823
+ # Get the code
824
+ code = command_data.get("code", "")
825
+
826
+ if not code:
827
+ console.print(f"[red]Command has no code: {command_name}[/red]")
828
+ return 1
829
+
830
+ # Add standalone wrapper if requested
831
+ if standalone:
832
+ # Check if already has if __name__ == '__main__'
833
+ if "if __name__" not in code:
834
+ code += "\n\nif __name__ == '__main__':\n app()\n"
835
+
836
+ # Determine output path
837
+ if not output:
838
+ output = f"{command_name}.py"
839
+
840
+ output_file = Path(output)
841
+
842
+ # Write the script
843
+ try:
844
+ with open(output_file, "w") as f:
845
+ f.write(code)
846
+ except Exception as e:
847
+ console.print(f"[red]Failed to write script: {e}[/red]")
848
+ return 1
849
+
850
+ console.print(f"[green]Exported command to script: {output_file}[/green]")
851
+ console.print(f"[dim]Source command: {command_name}[/dim]")
852
+
853
+ if standalone:
854
+ console.print(f"[dim]Run standalone with: python {output_file}[/dim]")
855
+
856
+ console.print(f"[dim]Edit and re-import with: mcli commands import {output_file} -s[/dim]")
624
857
 
625
- if manager.export_commands(export_path):
626
- console.print(f"[green]Exported custom commands to: {export_path}[/green]")
627
- console.print(
628
- f"[dim]Import on another machine with: mcli commands import {export_path}[/dim]"
629
- )
630
858
  return 0
631
859
  else:
632
- console.print("[red]Failed to export commands.[/red]")
633
- return 1
860
+ # Export all commands to JSON
861
+ export_file = target if target else "commands-export.json"
862
+ export_path = Path(export_file)
863
+
864
+ if manager.export_commands(export_path):
865
+ console.print(f"[green]Exported custom commands to: {export_path}[/green]")
866
+ console.print(
867
+ f"[dim]Import on another machine with: mcli commands import {export_path}[/dim]"
868
+ )
869
+ return 0
870
+ else:
871
+ console.print("[red]Failed to export commands.[/red]")
872
+ return 1
634
873
 
635
874
 
636
875
  @commands.command("import")
637
- @click.argument("import_file", type=click.Path(exists=True), required=True)
876
+ @click.argument("source", type=click.Path(exists=True), required=True)
877
+ @click.option("--script", "-s", is_flag=True, help="Import from Python script")
638
878
  @click.option("--overwrite", is_flag=True, help="Overwrite existing commands")
639
- def import_commands(import_file, overwrite):
879
+ @click.option("--name", "-n", help="Command name (for script import, defaults to script filename)")
880
+ @click.option("--group", "-g", default="workflow", help="Command group (for script import)")
881
+ @click.option("--description", "-d", help="Command description (for script import)")
882
+ @click.option(
883
+ "--interactive",
884
+ "-i",
885
+ is_flag=True,
886
+ help="Open in $EDITOR for review/editing (for script import)",
887
+ )
888
+ def import_commands(source, script, overwrite, name, group, description, interactive):
640
889
  """
641
- Import custom commands from a JSON file.
890
+ Import custom commands from JSON file or import a Python script as a command.
891
+
892
+ Default behavior (no flags): Import from JSON file
893
+ With --script/-s: Import a Python script as a command
894
+
895
+ Examples:
896
+ mcli commands import commands-export.json
897
+ mcli commands import my_script.py -s
898
+ mcli commands import my_script.py -s --name custom-cmd --interactive
642
899
  """
900
+ import subprocess
901
+
643
902
  manager = get_command_manager()
644
- import_path = Path(import_file)
903
+ source_path = Path(source)
645
904
 
646
- results = manager.import_commands(import_path, overwrite=overwrite)
905
+ if script:
906
+ # Import Python script as command
907
+ if not source_path.exists():
908
+ console.print(f"[red]Script not found: {source_path}[/red]")
909
+ return 1
647
910
 
648
- success_count = sum(1 for v in results.values() if v)
649
- failed_count = len(results) - success_count
911
+ # Read the script content
912
+ try:
913
+ with open(source_path, "r") as f:
914
+ code = f.read()
915
+ except Exception as e:
916
+ console.print(f"[red]Failed to read script: {e}[/red]")
917
+ return 1
650
918
 
651
- if success_count > 0:
652
- console.print(f"[green]Imported {success_count} command(s)[/green]")
919
+ # Determine command name
920
+ if not name:
921
+ name = source_path.stem.lower().replace("-", "_")
653
922
 
654
- if failed_count > 0:
655
- console.print(
656
- f"[yellow]Skipped {failed_count} command(s) (already exist, use --overwrite to replace)[/yellow]"
923
+ # Validate command name
924
+ if not re.match(r"^[a-z][a-z0-9_]*$", name):
925
+ console.print(f"[red]Invalid command name: {name}[/red]")
926
+ return 1
927
+
928
+ # Interactive editing
929
+ if interactive:
930
+ editor = os.environ.get("EDITOR", "vim")
931
+ console.print(f"Opening in {editor} for review...")
932
+
933
+ # Create temp file with the code
934
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as tmp:
935
+ tmp.write(code)
936
+ tmp_path = tmp.name
937
+
938
+ try:
939
+ subprocess.run([editor, tmp_path], check=True)
940
+
941
+ # Read edited content
942
+ with open(tmp_path, "r") as f:
943
+ code = f.read()
944
+ finally:
945
+ Path(tmp_path).unlink(missing_ok=True)
946
+
947
+ # Get description
948
+ if not description:
949
+ # Try to extract from docstring
950
+ import ast
951
+
952
+ try:
953
+ tree = ast.parse(code)
954
+ description = ast.get_docstring(tree) or f"Imported from {source_path.name}"
955
+ except:
956
+ description = f"Imported from {source_path.name}"
957
+
958
+ # Save as JSON command
959
+ saved_path = manager.save_command(
960
+ name=name,
961
+ code=code,
962
+ description=description,
963
+ group=group,
964
+ metadata={
965
+ "source": "import-script",
966
+ "original_file": str(source_path),
967
+ "imported_at": datetime.now().isoformat(),
968
+ },
657
969
  )
658
- console.print("Skipped commands:")
659
- for name, success in results.items():
660
- if not success:
661
- console.print(f" - {name}")
662
970
 
663
- return 0
971
+ console.print(f"[green]Imported script as command: {name}[/green]")
972
+ console.print(f"[dim]Saved to: {saved_path}[/dim]")
973
+ console.print(f"[dim]Use with: mcli {group} {name}[/dim]")
974
+ console.print("[dim]Command will be available after restart or reload[/dim]")
975
+
976
+ return 0
977
+ else:
978
+ # Import from JSON file
979
+ results = manager.import_commands(source_path, overwrite=overwrite)
980
+
981
+ success_count = sum(1 for v in results.values() if v)
982
+ failed_count = len(results) - success_count
983
+
984
+ if success_count > 0:
985
+ console.print(f"[green]Imported {success_count} command(s)[/green]")
986
+
987
+ if failed_count > 0:
988
+ console.print(
989
+ f"[yellow]Skipped {failed_count} command(s) (already exist, use --overwrite to replace)[/yellow]"
990
+ )
991
+ console.print("Skipped commands:")
992
+ for name, success in results.items():
993
+ if not success:
994
+ console.print(f" - {name}")
995
+
996
+ return 0
664
997
 
665
998
 
666
999
  @commands.command("verify")
@@ -811,169 +1144,579 @@ def edit_command(command_name, editor):
811
1144
  return 0
812
1145
 
813
1146
 
814
- @commands.command("import-script")
815
- @click.argument("script_path", type=click.Path(exists=True))
816
- @click.option("--name", "-n", help="Command name (defaults to script filename)")
817
- @click.option("--group", "-g", default="workflow", help="Command group")
818
- @click.option("--description", "-d", help="Command description")
819
- @click.option("--interactive", "-i", is_flag=True, help="Open in $EDITOR for review/editing")
820
- def import_script(script_path, name, group, description, interactive):
821
- """
822
- Import a Python script as a portable JSON command.
1147
+ # State management subgroup
1148
+ # Moved from mcli.self for better organization
823
1149
 
824
- Converts a Python script into a JSON command that can be loaded
825
- by mcli. The script should define Click commands.
826
1150
 
827
- Examples:
828
- mcli commands import-script my_script.py
829
- mcli commands import-script my_script.py --name custom-cmd --interactive
830
- """
831
- import subprocess
1151
+ @commands.group("state")
1152
+ def command_state():
1153
+ """Manage command state lockfile and history."""
1154
+ pass
832
1155
 
833
- script_file = Path(script_path).resolve()
834
1156
 
835
- if not script_file.exists():
836
- console.print(f"[red]Script not found: {script_file}[/red]")
837
- return 1
1157
+ @command_state.command("list")
1158
+ def list_states():
1159
+ """List all saved command states (hash, timestamp, #commands)."""
1160
+ states = load_lockfile()
1161
+ if not states:
1162
+ click.echo("No command states found.")
1163
+ return
1164
+
1165
+ table = Table(title="Command States")
1166
+ table.add_column("Hash", style="cyan")
1167
+ table.add_column("Timestamp", style="green")
1168
+ table.add_column("# Commands", style="yellow")
838
1169
 
839
- # Read the script content
1170
+ for state in states:
1171
+ table.add_row(state["hash"][:8], state["timestamp"], str(len(state["commands"])))
1172
+
1173
+ console.print(table)
1174
+
1175
+
1176
+ @command_state.command("restore")
1177
+ @click.argument("hash_value")
1178
+ def restore_state(hash_value):
1179
+ """Restore to a previous command state by hash."""
1180
+ if restore_command_state(hash_value):
1181
+ click.echo(f"Restored to state {hash_value[:8]}")
1182
+ else:
1183
+ click.echo(f"State {hash_value[:8]} not found.", err=True)
1184
+
1185
+
1186
+ @command_state.command("write")
1187
+ @click.argument("json_file", required=False, type=click.Path(exists=False))
1188
+ def write_state(json_file):
1189
+ """Write a new command state to the lockfile from a JSON file or the current app state."""
1190
+ import traceback
1191
+
1192
+ print("[DEBUG] write_state called")
1193
+ print(f"[DEBUG] LOCKFILE_PATH: {LOCKFILE_PATH}")
840
1194
  try:
841
- with open(script_file, "r") as f:
842
- code = f.read()
1195
+ if json_file:
1196
+ print(f"[DEBUG] Loading command state from file: {json_file}")
1197
+ with open(json_file, "r") as f:
1198
+ commands = json.load(f)
1199
+ click.echo(f"Loaded command state from {json_file}.")
1200
+ else:
1201
+ print("[DEBUG] Snapshotting current command state.")
1202
+ commands = get_current_command_state()
1203
+
1204
+ state_hash = hash_command_state(commands)
1205
+ new_state = {
1206
+ "hash": state_hash,
1207
+ "timestamp": datetime.utcnow().isoformat() + "Z",
1208
+ "commands": commands,
1209
+ }
1210
+ append_lockfile(new_state)
1211
+ print(f"[DEBUG] Wrote new command state {state_hash[:8]} to lockfile at {LOCKFILE_PATH}")
1212
+ click.echo(f"Wrote new command state {state_hash[:8]} to lockfile.")
843
1213
  except Exception as e:
844
- console.print(f"[red]Failed to read script: {e}[/red]")
845
- return 1
1214
+ print(f"[ERROR] Exception in write_state: {e}")
1215
+ print(traceback.format_exc())
1216
+ click.echo(f"[ERROR] Failed to write command state: {e}", err=True)
846
1217
 
847
- # Determine command name
848
- if not name:
849
- name = script_file.stem.lower().replace("-", "_")
850
1218
 
851
- # Validate command name
852
- if not re.match(r"^[a-z][a-z0-9_]*$", name):
853
- console.print(f"[red]Invalid command name: {name}[/red]")
854
- return 1
1219
+ # Store management subgroup
1220
+ # Moved from mcli.self for better organization
855
1221
 
856
- # Interactive editing
857
- if interactive:
858
- editor = os.environ.get("EDITOR", "vim")
859
- console.print(f"Opening in {editor} for review...")
860
1222
 
861
- # Create temp file with the code
862
- with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as tmp:
863
- tmp.write(code)
864
- tmp_path = tmp.name
1223
+ def _get_store_path() -> Path:
1224
+ """Get store path from config or default"""
1225
+ config_file = Path.home() / ".mcli" / "store.conf"
1226
+
1227
+ if config_file.exists():
1228
+ store_path = Path(config_file.read_text().strip())
1229
+ if store_path.exists():
1230
+ return store_path
1231
+
1232
+ # Use default
1233
+ return DEFAULT_STORE_PATH
1234
+
1235
+
1236
+ @commands.group("store")
1237
+ def store():
1238
+ """Manage command store - sync ~/.mcli/commands/ to git"""
1239
+ pass
1240
+
1241
+
1242
+ @store.command(name="init")
1243
+ @click.option("--path", "-p", type=click.Path(), help=f"Store path (default: {DEFAULT_STORE_PATH})")
1244
+ @click.option("--remote", "-r", help="Git remote URL (optional)")
1245
+ def init_store(path, remote):
1246
+ """Initialize command store with git"""
1247
+ store_path = Path(path) if path else DEFAULT_STORE_PATH
1248
+
1249
+ try:
1250
+ # Create store directory
1251
+ store_path.mkdir(parents=True, exist_ok=True)
1252
+
1253
+ # Initialize git if not already initialized
1254
+ git_dir = store_path / ".git"
1255
+ if not git_dir.exists():
1256
+ subprocess.run(["git", "init"], cwd=store_path, check=True, capture_output=True)
1257
+ success(f"Initialized git repository at {store_path}")
1258
+
1259
+ # Create .gitignore
1260
+ gitignore = store_path / ".gitignore"
1261
+ gitignore.write_text("*.backup\n.DS_Store\n")
1262
+
1263
+ # Create README
1264
+ readme = store_path / "README.md"
1265
+ readme.write_text(
1266
+ f"""# MCLI Commands Store
1267
+
1268
+ Personal workflow commands for mcli framework.
1269
+
1270
+ ## Usage
865
1271
 
1272
+ Push commands:
1273
+ ```bash
1274
+ mcli commands store push
1275
+ ```
1276
+
1277
+ Pull commands:
1278
+ ```bash
1279
+ mcli commands store pull
1280
+ ```
1281
+
1282
+ Sync (bidirectional):
1283
+ ```bash
1284
+ mcli commands store sync
1285
+ ```
1286
+
1287
+ ## Structure
1288
+
1289
+ All JSON command files from `~/.mcli/commands/` are stored here and version controlled.
1290
+
1291
+ Last updated: {datetime.now().isoformat()}
1292
+ """
1293
+ )
1294
+
1295
+ # Add remote if provided
1296
+ if remote:
1297
+ subprocess.run(
1298
+ ["git", "remote", "add", "origin", remote], cwd=store_path, check=True
1299
+ )
1300
+ success(f"Added remote: {remote}")
1301
+ else:
1302
+ info(f"Git repository already exists at {store_path}")
1303
+
1304
+ # Save store path to config
1305
+ config_file = Path.home() / ".mcli" / "store.conf"
1306
+ config_file.parent.mkdir(parents=True, exist_ok=True)
1307
+ config_file.write_text(str(store_path))
1308
+
1309
+ success(f"Command store initialized at {store_path}")
1310
+ info(f"Store path saved to {config_file}")
1311
+
1312
+ except subprocess.CalledProcessError as e:
1313
+ error(f"Git command failed: {e}")
1314
+ logger.error(f"Git init failed: {e}")
1315
+ except Exception as e:
1316
+ error(f"Failed to initialize store: {e}")
1317
+ logger.exception(e)
1318
+
1319
+
1320
+ @store.command(name="push")
1321
+ @click.option("--message", "-m", help="Commit message")
1322
+ @click.option("--all", "-a", is_flag=True, help="Push all files (including backups)")
1323
+ def push_commands(message, all):
1324
+ """Push commands from ~/.mcli/commands/ to git store"""
1325
+ try:
1326
+ store_path = _get_store_path()
1327
+
1328
+ # Copy commands to store
1329
+ info(f"Copying commands from {COMMANDS_PATH} to {store_path}...")
1330
+
1331
+ copied_count = 0
1332
+ for item in COMMANDS_PATH.glob("*"):
1333
+ # Skip backups unless --all specified
1334
+ if not all and item.name.endswith(".backup"):
1335
+ continue
1336
+
1337
+ dest = store_path / item.name
1338
+ if item.is_file():
1339
+ shutil.copy2(item, dest)
1340
+ copied_count += 1
1341
+ elif item.is_dir():
1342
+ shutil.copytree(item, dest, dirs_exist_ok=True)
1343
+ copied_count += 1
1344
+
1345
+ success(f"Copied {copied_count} items to store")
1346
+
1347
+ # Git add, commit, push
1348
+ subprocess.run(["git", "add", "."], cwd=store_path, check=True)
1349
+
1350
+ # Check if there are changes
1351
+ result = subprocess.run(
1352
+ ["git", "status", "--porcelain"], cwd=store_path, capture_output=True, text=True
1353
+ )
1354
+
1355
+ if not result.stdout.strip():
1356
+ info("No changes to commit")
1357
+ return
1358
+
1359
+ # Commit with message
1360
+ commit_msg = message or f"Update commands {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
1361
+ subprocess.run(["git", "commit", "-m", commit_msg], cwd=store_path, check=True)
1362
+ success(f"Committed changes: {commit_msg}")
1363
+
1364
+ # Push to remote if configured
866
1365
  try:
867
- subprocess.run([editor, tmp_path], check=True)
1366
+ subprocess.run(["git", "push"], cwd=store_path, check=True, capture_output=True)
1367
+ success("Pushed to remote")
1368
+ except subprocess.CalledProcessError:
1369
+ warning("No remote configured or push failed. Commands committed locally.")
1370
+
1371
+ except Exception as e:
1372
+ error(f"Failed to push commands: {e}")
1373
+ logger.exception(e)
868
1374
 
869
- # Read edited content
870
- with open(tmp_path, "r") as f:
871
- code = f.read()
872
- finally:
873
- Path(tmp_path).unlink(missing_ok=True)
874
1375
 
875
- # Get description
876
- if not description:
877
- # Try to extract from docstring
878
- import ast
1376
+ @store.command(name="pull")
1377
+ @click.option("--force", "-f", is_flag=True, help="Overwrite local commands without backup")
1378
+ def pull_commands(force):
1379
+ """Pull commands from git store to ~/.mcli/commands/"""
1380
+ try:
1381
+ store_path = _get_store_path()
879
1382
 
1383
+ # Pull from remote
880
1384
  try:
881
- tree = ast.parse(code)
882
- description = ast.get_docstring(tree) or f"Imported from {script_file.name}"
883
- except:
884
- description = f"Imported from {script_file.name}"
1385
+ subprocess.run(["git", "pull"], cwd=store_path, check=True)
1386
+ success("Pulled latest changes from remote")
1387
+ except subprocess.CalledProcessError:
1388
+ warning("No remote configured or pull failed. Using local store.")
1389
+
1390
+ # Backup existing commands if not force
1391
+ if not force and COMMANDS_PATH.exists():
1392
+ backup_dir = (
1393
+ COMMANDS_PATH.parent / f"commands_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
1394
+ )
1395
+ shutil.copytree(COMMANDS_PATH, backup_dir)
1396
+ info(f"Backed up existing commands to {backup_dir}")
885
1397
 
886
- # Save as JSON command
887
- manager = get_command_manager()
1398
+ # Copy from store to commands directory
1399
+ info(f"Copying commands from {store_path} to {COMMANDS_PATH}...")
888
1400
 
889
- saved_path = manager.save_command(
890
- name=name,
891
- code=code,
892
- description=description,
893
- group=group,
894
- metadata={
895
- "source": "import-script",
896
- "original_file": str(script_file),
897
- "imported_at": datetime.now().isoformat(),
898
- },
899
- )
1401
+ COMMANDS_PATH.mkdir(parents=True, exist_ok=True)
900
1402
 
901
- console.print(f"[green]Imported script as command: {name}[/green]")
902
- console.print(f"[dim]Saved to: {saved_path}[/dim]")
903
- console.print(f"[dim]Use with: mcli {group} {name}[/dim]")
904
- console.print("[dim]Command will be available after restart or reload[/dim]")
1403
+ copied_count = 0
1404
+ for item in store_path.glob("*"):
1405
+ # Skip git directory and README
1406
+ if item.name in [".git", "README.md", ".gitignore"]:
1407
+ continue
905
1408
 
906
- return 0
1409
+ dest = COMMANDS_PATH / item.name
1410
+ if item.is_file():
1411
+ shutil.copy2(item, dest)
1412
+ copied_count += 1
1413
+ elif item.is_dir():
1414
+ shutil.copytree(item, dest, dirs_exist_ok=True)
1415
+ copied_count += 1
907
1416
 
1417
+ success(f"Pulled {copied_count} items from store")
908
1418
 
909
- @commands.command("export-script")
910
- @click.argument("command_name")
911
- @click.option("--output", "-o", type=click.Path(), help="Output file path")
912
- @click.option(
913
- "--standalone",
914
- "-s",
915
- is_flag=True,
916
- help="Make script standalone (add if __name__ == '__main__')",
917
- )
918
- def export_script(command_name, output, standalone):
919
- """
920
- Export a JSON command to a Python script.
1419
+ except Exception as e:
1420
+ error(f"Failed to pull commands: {e}")
1421
+ logger.exception(e)
921
1422
 
922
- Converts a portable JSON command back to a standalone Python script
923
- that can be edited and run independently.
924
1423
 
925
- Examples:
926
- mcli commands export-script my-command
927
- mcli commands export-script my-command --output my_script.py --standalone
928
- """
929
- manager = get_command_manager()
1424
+ @store.command(name="sync")
1425
+ @click.option("--message", "-m", help="Commit message if pushing")
1426
+ def sync_commands(message):
1427
+ """Sync commands bidirectionally (pull then push if changes)"""
1428
+ try:
1429
+ store_path = _get_store_path()
930
1430
 
931
- # Load the command
932
- command_file = manager.commands_dir / f"{command_name}.json"
933
- if not command_file.exists():
934
- console.print(f"[red]Command not found: {command_name}[/red]")
935
- return 1
1431
+ # First pull
1432
+ info("Pulling latest changes...")
1433
+ try:
1434
+ subprocess.run(["git", "pull"], cwd=store_path, check=True, capture_output=True)
1435
+ success("Pulled from remote")
1436
+ except subprocess.CalledProcessError:
1437
+ warning("No remote or pull failed")
1438
+
1439
+ # Then push local changes
1440
+ info("Pushing local changes...")
1441
+
1442
+ # Copy commands
1443
+ for item in COMMANDS_PATH.glob("*"):
1444
+ if item.name.endswith(".backup"):
1445
+ continue
1446
+ dest = store_path / item.name
1447
+ if item.is_file():
1448
+ shutil.copy2(item, dest)
1449
+ elif item.is_dir():
1450
+ shutil.copytree(item, dest, dirs_exist_ok=True)
1451
+
1452
+ # Check for changes
1453
+ result = subprocess.run(
1454
+ ["git", "status", "--porcelain"], cwd=store_path, capture_output=True, text=True
1455
+ )
1456
+
1457
+ if not result.stdout.strip():
1458
+ success("Everything in sync!")
1459
+ return
1460
+
1461
+ # Commit and push
1462
+ subprocess.run(["git", "add", "."], cwd=store_path, check=True)
1463
+ commit_msg = message or f"Sync commands {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
1464
+ subprocess.run(["git", "commit", "-m", commit_msg], cwd=store_path, check=True)
1465
+
1466
+ try:
1467
+ subprocess.run(["git", "push"], cwd=store_path, check=True, capture_output=True)
1468
+ success("Synced and pushed to remote")
1469
+ except subprocess.CalledProcessError:
1470
+ success("Synced locally (no remote configured)")
936
1471
 
1472
+ except Exception as e:
1473
+ error(f"Sync failed: {e}")
1474
+ logger.exception(e)
1475
+
1476
+
1477
+ @store.command(name="status")
1478
+ def store_status():
1479
+ """Show git status of command store"""
937
1480
  try:
938
- with open(command_file, "r") as f:
939
- command_data = json.load(f)
1481
+ store_path = _get_store_path()
1482
+
1483
+ click.echo(f"\n📦 Store: {store_path}\n")
1484
+
1485
+ # Git status
1486
+ result = subprocess.run(
1487
+ ["git", "status", "--short", "--branch"], cwd=store_path, capture_output=True, text=True
1488
+ )
1489
+
1490
+ if result.stdout:
1491
+ click.echo(result.stdout)
1492
+
1493
+ # Show remote
1494
+ result = subprocess.run(
1495
+ ["git", "remote", "-v"], cwd=store_path, capture_output=True, text=True
1496
+ )
1497
+
1498
+ if result.stdout:
1499
+ click.echo("\n🌐 Remotes:")
1500
+ click.echo(result.stdout)
1501
+ else:
1502
+ info("\nNo remote configured")
1503
+
1504
+ click.echo()
1505
+
940
1506
  except Exception as e:
941
- console.print(f"[red]Failed to load command: {e}[/red]")
942
- return 1
1507
+ error(f"Failed to get status: {e}")
1508
+ logger.exception(e)
943
1509
 
944
- # Get the code
945
- code = command_data.get("code", "")
946
1510
 
947
- if not code:
948
- console.print(f"[red]Command has no code: {command_name}[/red]")
949
- return 1
1511
+ @store.command(name="config")
1512
+ @click.option("--remote", "-r", help="Set git remote URL")
1513
+ @click.option("--path", "-p", type=click.Path(), help="Change store path")
1514
+ def configure_store(remote, path):
1515
+ """Configure store settings"""
1516
+ try:
1517
+ store_path = _get_store_path()
950
1518
 
951
- # Add standalone wrapper if requested
952
- if standalone:
953
- # Check if already has if __name__ == '__main__'
954
- if "if __name__" not in code:
955
- code += "\n\nif __name__ == '__main__':\n app()\n"
1519
+ if path:
1520
+ new_path = Path(path).expanduser().resolve()
1521
+ config_file = Path.home() / ".mcli" / "store.conf"
1522
+ config_file.write_text(str(new_path))
1523
+ success(f"Store path updated to: {new_path}")
1524
+ return
956
1525
 
957
- # Determine output path
958
- if not output:
959
- output = f"{command_name}.py"
1526
+ if remote:
1527
+ # Check if remote exists
1528
+ result = subprocess.run(
1529
+ ["git", "remote"], cwd=store_path, capture_output=True, text=True
1530
+ )
1531
+
1532
+ if "origin" in result.stdout:
1533
+ subprocess.run(
1534
+ ["git", "remote", "set-url", "origin", remote], cwd=store_path, check=True
1535
+ )
1536
+ success(f"Updated remote URL: {remote}")
1537
+ else:
1538
+ subprocess.run(
1539
+ ["git", "remote", "add", "origin", remote], cwd=store_path, check=True
1540
+ )
1541
+ success(f"Added remote URL: {remote}")
1542
+
1543
+ except Exception as e:
1544
+ error(f"Configuration failed: {e}")
1545
+ logger.exception(e)
960
1546
 
961
- output_file = Path(output)
962
1547
 
963
- # Write the script
1548
+ @store.command(name="list")
1549
+ @click.option("--store", "-s", is_flag=True, help="List store instead of local")
1550
+ def list_commands(store):
1551
+ """List all commands"""
964
1552
  try:
965
- with open(output_file, "w") as f:
966
- f.write(code)
1553
+ if store:
1554
+ store_path = _get_store_path()
1555
+ path = store_path
1556
+ title = f"Commands in store ({store_path})"
1557
+ else:
1558
+ path = COMMANDS_PATH
1559
+ title = f"Local commands ({COMMANDS_PATH})"
1560
+
1561
+ click.echo(f"\n{title}:\n")
1562
+
1563
+ if not path.exists():
1564
+ warning(f"Directory does not exist: {path}")
1565
+ return
1566
+
1567
+ items = sorted(path.glob("*"))
1568
+ if not items:
1569
+ info("No commands found")
1570
+ return
1571
+
1572
+ for item in items:
1573
+ if item.name in [".git", ".gitignore", "README.md"]:
1574
+ continue
1575
+
1576
+ if item.is_file():
1577
+ size = item.stat().st_size / 1024
1578
+ modified = datetime.fromtimestamp(item.stat().st_mtime).strftime("%Y-%m-%d %H:%M")
1579
+ click.echo(f" 📄 {item.name:<40} {size:>8.1f} KB {modified}")
1580
+ elif item.is_dir():
1581
+ count = len(list(item.glob("*")))
1582
+ click.echo(f" 📁 {item.name:<40} {count:>3} files")
1583
+
1584
+ click.echo()
1585
+
967
1586
  except Exception as e:
968
- console.print(f"[red]Failed to write script: {e}[/red]")
969
- return 1
1587
+ error(f"Failed to list commands: {e}")
1588
+ logger.exception(e)
970
1589
 
971
- console.print(f"[green]Exported command to script: {output_file}[/green]")
972
- console.print(f"[dim]Source command: {command_name}[/dim]")
973
1590
 
974
- if standalone:
975
- console.print(f"[dim]Run standalone with: python {output_file}[/dim]")
1591
+ @store.command(name="show")
1592
+ @click.argument("command_name")
1593
+ @click.option("--store", "-s", is_flag=True, help="Show from store instead of local")
1594
+ def show_command(command_name, store):
1595
+ """Show command file contents"""
1596
+ try:
1597
+ if store:
1598
+ store_path = _get_store_path()
1599
+ path = store_path / command_name
1600
+ else:
1601
+ path = COMMANDS_PATH / command_name
976
1602
 
977
- console.print(f"[dim]Edit and re-import with: mcli commands import-script {output_file}[/dim]")
1603
+ if not path.exists():
1604
+ error(f"Command not found: {command_name}")
1605
+ return
978
1606
 
979
- return 0
1607
+ if path.is_file():
1608
+ click.echo(f"\n📄 {path}:\n")
1609
+ click.echo(path.read_text())
1610
+ else:
1611
+ info(f"{command_name} is a directory")
1612
+ for item in sorted(path.glob("*")):
1613
+ click.echo(f" {item.name}")
1614
+
1615
+ click.echo()
1616
+
1617
+ except Exception as e:
1618
+ error(f"Failed to show command: {e}")
1619
+ logger.exception(e)
1620
+
1621
+
1622
+ # Extract workflow commands command
1623
+ # Moved from mcli.self for better organization
1624
+
1625
+
1626
+ @commands.command("extract-workflow-commands")
1627
+ @click.option(
1628
+ "--output", "-o", type=click.Path(), help="Output file (default: workflow-commands.json)"
1629
+ )
1630
+ def extract_workflow_commands(output):
1631
+ """
1632
+ Extract workflow commands from Python modules to JSON format.
1633
+
1634
+ This command helps migrate existing workflow commands to portable JSON format.
1635
+ """
1636
+ output_file = Path(output) if output else Path("workflow-commands.json")
1637
+
1638
+ workflow_commands = []
1639
+
1640
+ # Try to get workflow from the main app
1641
+ try:
1642
+ from mcli.app.main import create_app
1643
+
1644
+ app = create_app()
1645
+
1646
+ # Check if workflow group exists
1647
+ if "workflow" in app.commands:
1648
+ workflow_group = app.commands["workflow"]
1649
+
1650
+ # Force load lazy group if needed
1651
+ if hasattr(workflow_group, "_load_group"):
1652
+ workflow_group = workflow_group._load_group()
1653
+
1654
+ if hasattr(workflow_group, "commands"):
1655
+ for cmd_name, cmd_obj in workflow_group.commands.items():
1656
+ # Extract command information
1657
+ command_info = {
1658
+ "name": cmd_name,
1659
+ "group": "workflow",
1660
+ "description": cmd_obj.help or "Workflow command",
1661
+ "version": "1.0",
1662
+ "metadata": {"source": "workflow", "migrated": True},
1663
+ }
1664
+
1665
+ # Create a template based on command type
1666
+ # Replace hyphens with underscores for valid Python function names
1667
+ safe_name = cmd_name.replace("-", "_")
1668
+
1669
+ if isinstance(cmd_obj, click.Group):
1670
+ # For groups, create a template
1671
+ command_info[
1672
+ "code"
1673
+ ] = f'''"""
1674
+ {cmd_name} workflow command.
1675
+ """
1676
+ import click
1677
+
1678
+ @click.group(name="{cmd_name}")
1679
+ def app():
1680
+ """{cmd_obj.help or 'Workflow command group'}"""
1681
+ pass
1682
+
1683
+ # Add your subcommands here
1684
+ '''
1685
+ else:
1686
+ # For regular commands, create a template
1687
+ command_info[
1688
+ "code"
1689
+ ] = f'''"""
1690
+ {cmd_name} workflow command.
1691
+ """
1692
+ import click
1693
+
1694
+ @click.command(name="{cmd_name}")
1695
+ def app():
1696
+ """{cmd_obj.help or 'Workflow command'}"""
1697
+ click.echo("Workflow command: {cmd_name}")
1698
+ # Add your implementation here
1699
+ '''
1700
+
1701
+ workflow_commands.append(command_info)
1702
+
1703
+ if workflow_commands:
1704
+ with open(output_file, "w") as f:
1705
+ json.dump(workflow_commands, f, indent=2)
1706
+
1707
+ click.echo(f"✅ Extracted {len(workflow_commands)} workflow commands")
1708
+ click.echo(f"📁 Saved to: {output_file}")
1709
+ click.echo(f"\n💡 These are templates. Import with: mcli commands import {output_file}")
1710
+ click.echo(" Then customize the code in ~/.mcli/commands/<command>.json")
1711
+ return 0
1712
+ else:
1713
+ click.echo("⚠️ No workflow commands found to extract")
1714
+ return 1
1715
+
1716
+ except Exception as e:
1717
+ logger.error(f"Failed to extract workflow commands: {e}")
1718
+ click.echo(f"❌ Failed to extract workflow commands: {e}", err=True)
1719
+ import traceback
1720
+
1721
+ click.echo(traceback.format_exc(), err=True)
1722
+ return 1