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.
- mcli/__init__.py +160 -0
- mcli/__main__.py +14 -0
- mcli/app/__init__.py +23 -0
- mcli/app/commands_cmd.py +942 -199
- mcli/app/main.py +5 -21
- mcli/app/model/__init__.py +0 -0
- mcli/app/model_cmd.py +57 -472
- mcli/app/video/__init__.py +5 -0
- mcli/chat/__init__.py +34 -0
- mcli/lib/__init__.py +0 -0
- mcli/lib/api/__init__.py +0 -0
- mcli/lib/auth/__init__.py +1 -0
- mcli/lib/config/__init__.py +1 -0
- mcli/lib/erd/__init__.py +25 -0
- mcli/lib/files/__init__.py +0 -0
- mcli/lib/fs/__init__.py +1 -0
- mcli/lib/logger/__init__.py +3 -0
- mcli/lib/performance/__init__.py +17 -0
- mcli/lib/pickles/__init__.py +1 -0
- mcli/lib/shell/__init__.py +0 -0
- mcli/lib/toml/__init__.py +1 -0
- mcli/lib/watcher/__init__.py +0 -0
- mcli/ml/__init__.py +16 -0
- mcli/ml/api/__init__.py +30 -0
- mcli/ml/api/routers/__init__.py +27 -0
- mcli/ml/auth/__init__.py +41 -0
- mcli/ml/backtesting/__init__.py +33 -0
- mcli/ml/cli/__init__.py +5 -0
- mcli/ml/config/__init__.py +33 -0
- mcli/ml/configs/__init__.py +16 -0
- mcli/ml/dashboard/__init__.py +12 -0
- mcli/ml/dashboard/app_supabase.py +57 -12
- mcli/ml/dashboard/components/__init__.py +7 -0
- mcli/ml/dashboard/pages/__init__.py +6 -0
- mcli/ml/dashboard/pages/predictions_enhanced.py +82 -38
- mcli/ml/dashboard/utils.py +39 -11
- mcli/ml/data_ingestion/__init__.py +29 -0
- mcli/ml/database/__init__.py +40 -0
- mcli/ml/experimentation/__init__.py +29 -0
- mcli/ml/features/__init__.py +39 -0
- mcli/ml/mlops/__init__.py +19 -0
- mcli/ml/models/__init__.py +90 -0
- mcli/ml/monitoring/__init__.py +25 -0
- mcli/ml/optimization/__init__.py +27 -0
- mcli/ml/predictions/__init__.py +5 -0
- mcli/ml/preprocessing/__init__.py +24 -0
- mcli/ml/scripts/__init__.py +1 -0
- mcli/ml/trading/__init__.py +63 -0
- mcli/ml/training/__init__.py +7 -0
- mcli/mygroup/__init__.py +3 -0
- mcli/public/__init__.py +1 -0
- mcli/public/commands/__init__.py +2 -0
- mcli/self/__init__.py +3 -0
- mcli/self/self_cmd.py +4 -253
- mcli/self/store_cmd.py +5 -3
- mcli/workflow/__init__.py +0 -0
- mcli/workflow/daemon/__init__.py +15 -0
- mcli/workflow/dashboard/__init__.py +5 -0
- mcli/workflow/dashboard/dashboard_cmd.py +1 -0
- mcli/workflow/docker/__init__.py +0 -0
- mcli/workflow/file/__init__.py +0 -0
- mcli/workflow/gcloud/__init__.py +1 -0
- mcli/workflow/git_commit/__init__.py +0 -0
- mcli/workflow/interview/__init__.py +0 -0
- mcli/workflow/politician_trading/__init__.py +4 -0
- mcli/workflow/registry/__init__.py +0 -0
- mcli/workflow/repo/__init__.py +0 -0
- mcli/workflow/scheduler/__init__.py +25 -0
- mcli/workflow/search/__init__.py +0 -0
- mcli/workflow/sync/__init__.py +5 -0
- mcli/workflow/videos/__init__.py +1 -0
- mcli/workflow/wakatime/__init__.py +80 -0
- {mcli_framework-7.8.3.dist-info → mcli_framework-7.8.5.dist-info}/METADATA +1 -1
- {mcli_framework-7.8.3.dist-info → mcli_framework-7.8.5.dist-info}/RECORD +78 -18
- mcli/app/chat_cmd.py +0 -42
- mcli/test/cron_test_cmd.py +0 -697
- mcli/test/test_cmd.py +0 -30
- {mcli_framework-7.8.3.dist-info → mcli_framework-7.8.5.dist-info}/WHEEL +0 -0
- {mcli_framework-7.8.3.dist-info → mcli_framework-7.8.5.dist-info}/entry_points.txt +0 -0
- {mcli_framework-7.8.3.dist-info → mcli_framework-7.8.5.dist-info}/licenses/LICENSE +0 -0
- {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
|
-
"""
|
|
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
|
|
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("
|
|
612
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
621
|
-
|
|
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
|
-
|
|
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
|
-
|
|
633
|
-
|
|
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("
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
903
|
+
source_path = Path(source)
|
|
645
904
|
|
|
646
|
-
|
|
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
|
-
|
|
649
|
-
|
|
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
|
-
|
|
652
|
-
|
|
919
|
+
# Determine command name
|
|
920
|
+
if not name:
|
|
921
|
+
name = source_path.stem.lower().replace("-", "_")
|
|
653
922
|
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
f"[
|
|
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
|
-
|
|
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
|
-
|
|
815
|
-
|
|
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
|
-
|
|
828
|
-
|
|
829
|
-
|
|
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
|
-
|
|
836
|
-
|
|
837
|
-
|
|
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
|
-
|
|
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
|
-
|
|
842
|
-
|
|
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
|
-
|
|
845
|
-
|
|
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
|
-
|
|
852
|
-
|
|
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
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
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([
|
|
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
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
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
|
-
|
|
882
|
-
|
|
883
|
-
except:
|
|
884
|
-
|
|
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
|
-
|
|
887
|
-
|
|
1398
|
+
# Copy from store to commands directory
|
|
1399
|
+
info(f"Copying commands from {store_path} to {COMMANDS_PATH}...")
|
|
888
1400
|
|
|
889
|
-
|
|
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
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
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
|
-
|
|
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
|
-
|
|
910
|
-
|
|
911
|
-
|
|
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
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
"""
|
|
929
|
-
|
|
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
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
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
|
-
|
|
939
|
-
|
|
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
|
-
|
|
942
|
-
|
|
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
|
-
|
|
948
|
-
|
|
949
|
-
|
|
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
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
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
|
-
|
|
958
|
-
|
|
959
|
-
|
|
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
|
-
|
|
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
|
-
|
|
966
|
-
|
|
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
|
-
|
|
969
|
-
|
|
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
|
-
|
|
975
|
-
|
|
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
|
-
|
|
1603
|
+
if not path.exists():
|
|
1604
|
+
error(f"Command not found: {command_name}")
|
|
1605
|
+
return
|
|
978
1606
|
|
|
979
|
-
|
|
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
|