mcli-framework 7.12.3__py3-none-any.whl → 7.13.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of mcli-framework might be problematic. Click here for more details.

mcli/app/commands_cmd.py CHANGED
@@ -4,6 +4,7 @@ import inspect
4
4
  import json
5
5
  import os
6
6
  import re
7
+ import shutil
7
8
  import subprocess
8
9
  import tempfile
9
10
  from datetime import datetime
@@ -17,7 +18,7 @@ from mcli.lib.api.daemon_client import get_daemon_client
17
18
  from mcli.lib.custom_commands import get_command_manager
18
19
  from mcli.lib.discovery.command_discovery import get_command_discovery
19
20
  from mcli.lib.logger.logger import get_logger
20
- from mcli.lib.ui.styling import console
21
+ from mcli.lib.ui.styling import console, error, info, success, warning
21
22
 
22
23
  logger = get_logger(__name__)
23
24
 
@@ -153,11 +154,19 @@ def append_lockfile(new_state):
153
154
 
154
155
 
155
156
  def find_state_by_hash(hash_value):
156
- """Find a state by its hash value."""
157
+ """Find a state by its hash value (supports partial hash matching)."""
157
158
  states = load_lockfile()
159
+ matches = []
158
160
  for state in states:
159
- if state["hash"] == hash_value:
160
- return state
161
+ # Support both full hash and partial hash (prefix) matching
162
+ if state["hash"] == hash_value or state["hash"].startswith(hash_value):
163
+ matches.append(state)
164
+
165
+ if len(matches) == 1:
166
+ return matches[0]
167
+ elif len(matches) > 1:
168
+ # Ambiguous - multiple matches
169
+ return None
161
170
  return None
162
171
 
163
172
 
@@ -181,6 +190,390 @@ def workflow():
181
190
  commands = workflow
182
191
 
183
192
 
193
+ # Helper function for store commands
194
+ def _get_store_path() -> Path:
195
+ """Get store path from config or default."""
196
+ config_file = Path.home() / ".mcli" / "store.conf"
197
+
198
+ if config_file.exists():
199
+ store_path = Path(config_file.read_text().strip())
200
+ if store_path.exists():
201
+ return store_path
202
+
203
+ # Use default
204
+ return DEFAULT_STORE_PATH
205
+
206
+
207
+ # Store commands (git sync for workflows)
208
+
209
+
210
+ @workflow.command(name="init")
211
+ @click.option("--path", "-p", type=click.Path(), help=f"Store path (default: {DEFAULT_STORE_PATH})")
212
+ @click.option("--remote", "-r", help="Git remote URL (optional)")
213
+ def init_store(path, remote):
214
+ """Initialize command store with git."""
215
+ store_path = Path(path) if path else DEFAULT_STORE_PATH
216
+
217
+ try:
218
+ # Create store directory
219
+ store_path.mkdir(parents=True, exist_ok=True)
220
+
221
+ # Initialize git if not already initialized
222
+ git_dir = store_path / ".git"
223
+ if not git_dir.exists():
224
+ subprocess.run(["git", "init"], cwd=store_path, check=True, capture_output=True)
225
+ success(f"Initialized git repository at {store_path}")
226
+
227
+ # Create .gitignore
228
+ gitignore = store_path / ".gitignore"
229
+ gitignore.write_text("*.backup\n.DS_Store\n")
230
+
231
+ # Create README
232
+ readme = store_path / "README.md"
233
+ readme.write_text(
234
+ f"""# MCLI Commands Store
235
+
236
+ Personal workflow commands for mcli framework.
237
+
238
+ ## Usage
239
+
240
+ Push commands:
241
+ ```bash
242
+ mcli workflow push
243
+ ```
244
+
245
+ Pull commands:
246
+ ```bash
247
+ mcli workflow pull
248
+ ```
249
+
250
+ Sync (bidirectional):
251
+ ```bash
252
+ mcli workflow sync
253
+ ```
254
+
255
+ ## Structure
256
+
257
+ All JSON command files from `~/.mcli/commands/` are stored here and version controlled.
258
+
259
+ Last updated: {datetime.now().isoformat()}
260
+ """
261
+ )
262
+
263
+ # Add remote if provided
264
+ if remote:
265
+ subprocess.run(
266
+ ["git", "remote", "add", "origin", remote], cwd=store_path, check=True
267
+ )
268
+ success(f"Added remote: {remote}")
269
+ else:
270
+ info(f"Git repository already exists at {store_path}")
271
+
272
+ # Save store path to config
273
+ config_file = Path.home() / ".mcli" / "store.conf"
274
+ config_file.parent.mkdir(parents=True, exist_ok=True)
275
+ config_file.write_text(str(store_path))
276
+
277
+ success(f"Command store initialized at {store_path}")
278
+ info(f"Store path saved to {config_file}")
279
+
280
+ except subprocess.CalledProcessError as e:
281
+ error(f"Git command failed: {e}")
282
+ logger.error(f"Git init failed: {e}")
283
+ except Exception as e:
284
+ error(f"Failed to initialize store: {e}")
285
+ logger.exception(e)
286
+
287
+
288
+ @workflow.command(name="push")
289
+ @click.option("--message", "-m", help="Commit message")
290
+ @click.option("--all", "-a", is_flag=True, help="Push all files (including backups)")
291
+ @click.option(
292
+ "--global", "-g", "is_global", is_flag=True, help="Push global commands instead of local"
293
+ )
294
+ def push_commands(message, all, is_global):
295
+ """
296
+ Push commands to git store.
297
+
298
+ By default pushes local commands (if in git repo), use --global/-g for global commands.
299
+ """
300
+ try:
301
+ store_path = _get_store_path()
302
+ from mcli.lib.paths import get_custom_commands_dir
303
+
304
+ COMMANDS_PATH = get_custom_commands_dir(global_mode=is_global)
305
+
306
+ # Copy commands to store
307
+ info(f"Copying commands from {COMMANDS_PATH} to {store_path}...")
308
+
309
+ copied_count = 0
310
+ for item in COMMANDS_PATH.glob("*"):
311
+ # Skip backups unless --all specified
312
+ if not all and item.name.endswith(".backup"):
313
+ continue
314
+
315
+ dest = store_path / item.name
316
+ if item.is_file():
317
+ shutil.copy2(item, dest)
318
+ copied_count += 1
319
+ elif item.is_dir():
320
+ shutil.copytree(item, dest, dirs_exist_ok=True)
321
+ copied_count += 1
322
+
323
+ success(f"Copied {copied_count} items to store")
324
+
325
+ # Git add, commit, push
326
+ subprocess.run(["git", "add", "."], cwd=store_path, check=True)
327
+
328
+ # Check if there are changes
329
+ result = subprocess.run(
330
+ ["git", "status", "--porcelain"], cwd=store_path, capture_output=True, text=True
331
+ )
332
+
333
+ if not result.stdout.strip():
334
+ info("No changes to commit")
335
+ return
336
+
337
+ # Commit with message
338
+ commit_msg = message or f"Update commands {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
339
+ subprocess.run(["git", "commit", "-m", commit_msg], cwd=store_path, check=True)
340
+ success(f"Committed changes: {commit_msg}")
341
+
342
+ # Push to remote if configured
343
+ try:
344
+ subprocess.run(["git", "push"], cwd=store_path, check=True, capture_output=True)
345
+ success("Pushed to remote")
346
+ except subprocess.CalledProcessError:
347
+ warning("No remote configured or push failed. Commands committed locally.")
348
+
349
+ except Exception as e:
350
+ error(f"Failed to push commands: {e}")
351
+ logger.exception(e)
352
+
353
+
354
+ @workflow.command(name="pull")
355
+ @click.option("--force", "-f", is_flag=True, help="Overwrite local commands without backup")
356
+ @click.option(
357
+ "--global", "-g", "is_global", is_flag=True, help="Pull to global commands instead of local"
358
+ )
359
+ def pull_commands(force, is_global):
360
+ """
361
+ Pull commands from git store.
362
+
363
+ By default pulls to local commands (if in git repo), use --global/-g for global commands.
364
+ """
365
+ try:
366
+ store_path = _get_store_path()
367
+ from mcli.lib.paths import get_custom_commands_dir
368
+
369
+ COMMANDS_PATH = get_custom_commands_dir(global_mode=is_global)
370
+
371
+ # Pull from remote
372
+ try:
373
+ subprocess.run(["git", "pull"], cwd=store_path, check=True)
374
+ success("Pulled latest changes from remote")
375
+ except subprocess.CalledProcessError:
376
+ warning("No remote configured or pull failed. Using local store.")
377
+
378
+ # Backup existing commands if not force
379
+ if not force and COMMANDS_PATH.exists():
380
+ backup_dir = (
381
+ COMMANDS_PATH.parent / f"commands_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
382
+ )
383
+ shutil.copytree(COMMANDS_PATH, backup_dir)
384
+ info(f"Backed up existing commands to {backup_dir}")
385
+
386
+ # Copy from store to commands directory
387
+ info(f"Copying commands from {store_path} to {COMMANDS_PATH}...")
388
+
389
+ COMMANDS_PATH.mkdir(parents=True, exist_ok=True)
390
+
391
+ copied_count = 0
392
+ for item in store_path.glob("*"):
393
+ # Skip git directory and README
394
+ if item.name in [".git", "README.md", ".gitignore"]:
395
+ continue
396
+
397
+ dest = COMMANDS_PATH / item.name
398
+ if item.is_file():
399
+ shutil.copy2(item, dest)
400
+ copied_count += 1
401
+ elif item.is_dir():
402
+ shutil.copytree(item, dest, dirs_exist_ok=True)
403
+ copied_count += 1
404
+
405
+ success(f"Pulled {copied_count} items from store")
406
+
407
+ except Exception as e:
408
+ error(f"Failed to pull commands: {e}")
409
+ logger.exception(e)
410
+
411
+
412
+ @workflow.command(name="sync")
413
+ @click.option("--message", "-m", help="Commit message if pushing")
414
+ @click.option(
415
+ "--global", "-g", "is_global", is_flag=True, help="Sync global commands instead of local"
416
+ )
417
+ def sync_commands(message, is_global):
418
+ """
419
+ Sync commands bidirectionally (pull then push if changes).
420
+
421
+ By default syncs local commands (if in git repo), use --global/-g for global commands.
422
+ """
423
+ try:
424
+ store_path = _get_store_path()
425
+ from mcli.lib.paths import get_custom_commands_dir
426
+
427
+ COMMANDS_PATH = get_custom_commands_dir(global_mode=is_global)
428
+
429
+ # First pull
430
+ info("Pulling latest changes...")
431
+ try:
432
+ subprocess.run(["git", "pull"], cwd=store_path, check=True, capture_output=True)
433
+ success("Pulled from remote")
434
+ except subprocess.CalledProcessError:
435
+ warning("No remote or pull failed")
436
+
437
+ # Then push local changes
438
+ info("Pushing local changes...")
439
+
440
+ # Copy commands
441
+ for item in COMMANDS_PATH.glob("*"):
442
+ if item.name.endswith(".backup"):
443
+ continue
444
+ dest = store_path / item.name
445
+ if item.is_file():
446
+ shutil.copy2(item, dest)
447
+ elif item.is_dir():
448
+ shutil.copytree(item, dest, dirs_exist_ok=True)
449
+
450
+ # Check for changes
451
+ result = subprocess.run(
452
+ ["git", "status", "--porcelain"], cwd=store_path, capture_output=True, text=True
453
+ )
454
+
455
+ if not result.stdout.strip():
456
+ success("Everything in sync!")
457
+ return
458
+
459
+ # Commit and push
460
+ subprocess.run(["git", "add", "."], cwd=store_path, check=True)
461
+ commit_msg = message or f"Sync commands {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
462
+ subprocess.run(["git", "commit", "-m", commit_msg], cwd=store_path, check=True)
463
+
464
+ try:
465
+ subprocess.run(["git", "push"], cwd=store_path, check=True, capture_output=True)
466
+ success("Synced and pushed to remote")
467
+ except subprocess.CalledProcessError:
468
+ success("Synced locally (no remote configured)")
469
+
470
+ except Exception as e:
471
+ error(f"Sync failed: {e}")
472
+ logger.exception(e)
473
+
474
+
475
+ @workflow.command(name="status")
476
+ def store_status():
477
+ """Show git status of command store."""
478
+ try:
479
+ store_path = _get_store_path()
480
+
481
+ click.echo(f"\n📦 Store: {store_path}\n")
482
+
483
+ # Git status
484
+ result = subprocess.run(
485
+ ["git", "status", "--short", "--branch"], cwd=store_path, capture_output=True, text=True
486
+ )
487
+
488
+ if result.stdout:
489
+ click.echo(result.stdout)
490
+
491
+ # Show remote
492
+ result = subprocess.run(
493
+ ["git", "remote", "-v"], cwd=store_path, capture_output=True, text=True
494
+ )
495
+
496
+ if result.stdout:
497
+ click.echo("\n🌐 Remotes:")
498
+ click.echo(result.stdout)
499
+ else:
500
+ info("\nNo remote configured")
501
+
502
+ click.echo()
503
+
504
+ except Exception as e:
505
+ error(f"Failed to get status: {e}")
506
+ logger.exception(e)
507
+
508
+
509
+ @workflow.command(name="config")
510
+ @click.option("--remote", "-r", help="Set git remote URL")
511
+ @click.option("--path", "-p", type=click.Path(), help="Change store path")
512
+ def configure_store(remote, path):
513
+ """Configure store settings."""
514
+ try:
515
+ store_path = _get_store_path()
516
+
517
+ if path:
518
+ new_path = Path(path).expanduser().resolve()
519
+ config_file = Path.home() / ".mcli" / "store.conf"
520
+ config_file.write_text(str(new_path))
521
+ success(f"Store path updated to: {new_path}")
522
+ return
523
+
524
+ if remote:
525
+ # Check if remote exists
526
+ result = subprocess.run(
527
+ ["git", "remote"], cwd=store_path, capture_output=True, text=True
528
+ )
529
+
530
+ if "origin" in result.stdout:
531
+ subprocess.run(
532
+ ["git", "remote", "set-url", "origin", remote], cwd=store_path, check=True
533
+ )
534
+ success(f"Updated remote URL: {remote}")
535
+ else:
536
+ subprocess.run(
537
+ ["git", "remote", "add", "origin", remote], cwd=store_path, check=True
538
+ )
539
+ success(f"Added remote URL: {remote}")
540
+
541
+ except Exception as e:
542
+ error(f"Configuration failed: {e}")
543
+ logger.exception(e)
544
+
545
+
546
+ @workflow.command(name="show")
547
+ @click.argument("command_name")
548
+ @click.option("--store-dir", "-s", is_flag=True, help="Show from store instead of local")
549
+ def show_command(command_name, store_dir):
550
+ """Show command file contents."""
551
+ try:
552
+ if store_dir:
553
+ store_path = _get_store_path()
554
+ path = store_path / command_name
555
+ else:
556
+ path = COMMANDS_PATH / command_name
557
+
558
+ if not path.exists():
559
+ error(f"Command not found: {command_name}")
560
+ return
561
+
562
+ if path.is_file():
563
+ click.echo(f"\n📄 {path}:\n")
564
+ click.echo(path.read_text())
565
+ else:
566
+ info(f"{command_name} is a directory")
567
+ for item in sorted(path.glob("*")):
568
+ click.echo(f" {item.name}")
569
+
570
+ click.echo()
571
+
572
+ except Exception as e:
573
+ error(f"Failed to show command: {e}")
574
+ logger.exception(e)
575
+
576
+
184
577
  # init command moved to init_cmd.py as top-level command
185
578
 
186
579
 
@@ -1316,7 +1709,7 @@ def edit_command(command_name, editor, is_global):
1316
1709
  # Moved from mcli.self for better organization
1317
1710
 
1318
1711
 
1319
- @workflow.command("extract-workflow-commands")
1712
+ @workflow.command("extract")
1320
1713
  @click.option(
1321
1714
  "--output", "-o", type=click.Path(), help="Output file (default: workflow-commands.json)"
1322
1715
  )
mcli/app/lock_cmd.py CHANGED
@@ -48,11 +48,19 @@ def append_lockfile(new_state):
48
48
 
49
49
 
50
50
  def find_state_by_hash(hash_value):
51
- """Find a state by its hash value."""
51
+ """Find a state by its hash value (supports partial hash matching)."""
52
52
  states = load_lockfile()
53
+ matches = []
53
54
  for state in states:
54
- if state["hash"] == hash_value:
55
- return state
55
+ # Support both full hash and partial hash (prefix) matching
56
+ if state["hash"] == hash_value or state["hash"].startswith(hash_value):
57
+ matches.append(state)
58
+
59
+ if len(matches) == 1:
60
+ return matches[0]
61
+ elif len(matches) > 1:
62
+ # Ambiguous - multiple matches
63
+ return None
56
64
  return None
57
65
 
58
66
 
mcli/app/main.py CHANGED
@@ -347,14 +347,7 @@ def _add_lazy_commands(app: click.Group):
347
347
  except ImportError as e:
348
348
  logger.debug(f"Could not load lock group: {e}")
349
349
 
350
- # Top-level store group
351
- try:
352
- from mcli.app.store_cmd import store
353
-
354
- app.add_command(store, name="store")
355
- logger.debug("Added store group")
356
- except ImportError as e:
357
- logger.debug(f"Could not load store group: {e}")
350
+ # Store commands moved to workflow group
358
351
 
359
352
  # Workflow management - load immediately for fast access (renamed from 'commands')
360
353
  try:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcli-framework
3
- Version: 7.12.3
3
+ Version: 7.13.0
4
4
  Summary: Portable workflow framework - transform any script into a versioned, schedulable command. Store in ~/.mcli/workflows/, version with lockfile, run as daemon or cron job.
5
5
  Author-email: Luis Fernandez de la Vara <luis@lefv.io>
6
6
  Maintainer-email: Luis Fernandez de la Vara <luis@lefv.io>
@@ -3,13 +3,12 @@ mcli/__main__.py,sha256=nKdf3WqtXi5PhWhZGjpXKAT3a2yGUYkYCBgSLxk4hSQ,295
3
3
  mcli/cli.py,sha256=3OgzOodRtHGZnsq5H3Fy-hl7JKwjJZa5ig2L4-izK2E,382
4
4
  mcli/config.toml,sha256=263yEVvP_W9F2zOLssUBgy7amKaRAFQuBrfxcMhKxaQ,1706
5
5
  mcli/app/__init__.py,sha256=UWt7rsI_lHjZ29_iH5lX6qSLt__uBMmp5W-Rt3BEG0E,447
6
- mcli/app/commands_cmd.py,sha256=-ZMnqUjEhgRYEYXHYEcwXwZjtxu0zLxQOXqSOHg9w80,50298
6
+ mcli/app/commands_cmd.py,sha256=A-YhX8dATAUTQe8c9jzLnwJXIdPKEdzYyZWcYVhIFwg,63098
7
7
  mcli/app/completion_helpers.py,sha256=_gBHJukhseferp3G7pj6QkDvAhVt72nsfbyOHcnBazg,7484
8
8
  mcli/app/init_cmd.py,sha256=VWeCAaN5tinFBBHgzmt_JR3UdSTNVBofXXRZ4FNdkcw,12428
9
- mcli/app/lock_cmd.py,sha256=nkTTvt0UNpMuvwpxIOtVPBhGbMhR0jdRF6J_rP-ODaU,12577
10
- mcli/app/main.py,sha256=eSrgA4C0vePllEuH2TSqyBpOxAbyAEkc-rUZk77KQBw,19076
9
+ mcli/app/lock_cmd.py,sha256=0m7xkpWxfJ8vxTgyvRNY2XKAwJj_CB7Sf-wvnte5RS4,12883
10
+ mcli/app/main.py,sha256=ppQm1Jl8x6sub3_2X3eot00pYham8FeHg8l0OV0Nw9I,18865
11
11
  mcli/app/model_cmd.py,sha256=LQQD8FaebFoaJGK3u_kt19wZ3HJyo_ecwSMYyC2xIp8,2497
12
- mcli/app/store_cmd.py,sha256=N7FhnrhIeY6Hetv6kW4mcJ97EQvhgh0pwHiwoZSfzhU,14315
13
12
  mcli/app/model/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
13
  mcli/app/model/model.py,sha256=w3yrZ2bgh47V0SlpNHcuMkBX0fC9gni66VEwpBg0iYU,38968
15
14
  mcli/app/video/__init__.py,sha256=NS3kaM8lWKL-gVh7WLGBqNda0-QJS6MCY6iW_GWPUco,44
@@ -271,9 +270,9 @@ mcli/workflow/sync/test_cmd.py,sha256=E-DItNtiBTGE9ofmfrjm9qjgYjeBpMXp08fmRk6nhR
271
270
  mcli/workflow/videos/__init__.py,sha256=aV3DEoO7qdKJY4odWKoQbOKDQq4ludTeCLnZcupOFIM,25
272
271
  mcli/workflow/wakatime/__init__.py,sha256=gD6SKA7dJ28HXFd1w04YuB_WgL0SWosbEFtT2uS__pg,2595
273
272
  mcli/workflow/wakatime/wakatime.py,sha256=ig86g04qXgRrx6UXsIKd9Cv9OFhJnQLL2Plylf8TMAA,134
274
- mcli_framework-7.12.3.dist-info/licenses/LICENSE,sha256=sahwAMfrJv2-V66HNPTp7A9UmMjxtyejwTZZoWQvEcI,1075
275
- mcli_framework-7.12.3.dist-info/METADATA,sha256=MCbvO8aDP_lYdW6GLTu2Yfe9sA9i-8rLgutymLuVrko,18635
276
- mcli_framework-7.12.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
277
- mcli_framework-7.12.3.dist-info/entry_points.txt,sha256=dYrZbDIm-KUPsl1wfv600Kx_8sMy89phMkCihbDRgP8,261
278
- mcli_framework-7.12.3.dist-info/top_level.txt,sha256=_bnO8J2EUkliWivey_1le0UrnocFKmyVMQjbQ8iVXjc,5
279
- mcli_framework-7.12.3.dist-info/RECORD,,
273
+ mcli_framework-7.13.0.dist-info/licenses/LICENSE,sha256=sahwAMfrJv2-V66HNPTp7A9UmMjxtyejwTZZoWQvEcI,1075
274
+ mcli_framework-7.13.0.dist-info/METADATA,sha256=-QZ_QbgHr6odb5UvHD7pzy5b3mpNRreBagwT70IdV-Q,18635
275
+ mcli_framework-7.13.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
276
+ mcli_framework-7.13.0.dist-info/entry_points.txt,sha256=dYrZbDIm-KUPsl1wfv600Kx_8sMy89phMkCihbDRgP8,261
277
+ mcli_framework-7.13.0.dist-info/top_level.txt,sha256=_bnO8J2EUkliWivey_1le0UrnocFKmyVMQjbQ8iVXjc,5
278
+ mcli_framework-7.13.0.dist-info/RECORD,,
mcli/app/store_cmd.py DELETED
@@ -1,448 +0,0 @@
1
- """
2
- Top-level store management commands for MCLI.
3
- Manages command store - sync ~/.mcli/commands/ to git.
4
- """
5
-
6
- import shutil
7
- import subprocess
8
- from datetime import datetime
9
- from pathlib import Path
10
-
11
- import click
12
-
13
- from mcli.lib.logger.logger import get_logger
14
- from mcli.lib.ui.styling import error, info, success, warning
15
-
16
- logger = get_logger(__name__)
17
-
18
- # Command store configuration
19
- DEFAULT_STORE_PATH = Path.home() / "repos" / "mcli-commands"
20
- COMMANDS_PATH = Path.home() / ".mcli" / "commands"
21
-
22
-
23
- def _get_store_path() -> Path:
24
- """Get store path from config or default."""
25
- config_file = Path.home() / ".mcli" / "store.conf"
26
-
27
- if config_file.exists():
28
- store_path = Path(config_file.read_text().strip())
29
- if store_path.exists():
30
- return store_path
31
-
32
- # Use default
33
- return DEFAULT_STORE_PATH
34
-
35
-
36
- @click.group(name="store")
37
- def store():
38
- """Manage command store - sync ~/.mcli/commands/ to git."""
39
-
40
-
41
- @store.command(name="init")
42
- @click.option("--path", "-p", type=click.Path(), help=f"Store path (default: {DEFAULT_STORE_PATH})")
43
- @click.option("--remote", "-r", help="Git remote URL (optional)")
44
- def init_store(path, remote):
45
- """Initialize command store with git."""
46
- store_path = Path(path) if path else DEFAULT_STORE_PATH
47
-
48
- try:
49
- # Create store directory
50
- store_path.mkdir(parents=True, exist_ok=True)
51
-
52
- # Initialize git if not already initialized
53
- git_dir = store_path / ".git"
54
- if not git_dir.exists():
55
- subprocess.run(["git", "init"], cwd=store_path, check=True, capture_output=True)
56
- success(f"Initialized git repository at {store_path}")
57
-
58
- # Create .gitignore
59
- gitignore = store_path / ".gitignore"
60
- gitignore.write_text("*.backup\n.DS_Store\n")
61
-
62
- # Create README
63
- readme = store_path / "README.md"
64
- readme.write_text(
65
- f"""# MCLI Commands Store
66
-
67
- Personal workflow commands for mcli framework.
68
-
69
- ## Usage
70
-
71
- Push commands:
72
- ```bash
73
- mcli store push
74
- ```
75
-
76
- Pull commands:
77
- ```bash
78
- mcli store pull
79
- ```
80
-
81
- Sync (bidirectional):
82
- ```bash
83
- mcli store sync
84
- ```
85
-
86
- ## Structure
87
-
88
- All JSON command files from `~/.mcli/commands/` are stored here and version controlled.
89
-
90
- Last updated: {datetime.now().isoformat()}
91
- """
92
- )
93
-
94
- # Add remote if provided
95
- if remote:
96
- subprocess.run(
97
- ["git", "remote", "add", "origin", remote], cwd=store_path, check=True
98
- )
99
- success(f"Added remote: {remote}")
100
- else:
101
- info(f"Git repository already exists at {store_path}")
102
-
103
- # Save store path to config
104
- config_file = Path.home() / ".mcli" / "store.conf"
105
- config_file.parent.mkdir(parents=True, exist_ok=True)
106
- config_file.write_text(str(store_path))
107
-
108
- success(f"Command store initialized at {store_path}")
109
- info(f"Store path saved to {config_file}")
110
-
111
- except subprocess.CalledProcessError as e:
112
- error(f"Git command failed: {e}")
113
- logger.error(f"Git init failed: {e}")
114
- except Exception as e:
115
- error(f"Failed to initialize store: {e}")
116
- logger.exception(e)
117
-
118
-
119
- @store.command(name="push")
120
- @click.option("--message", "-m", help="Commit message")
121
- @click.option("--all", "-a", is_flag=True, help="Push all files (including backups)")
122
- @click.option(
123
- "--global", "-g", "is_global", is_flag=True, help="Push global commands instead of local"
124
- )
125
- def push_commands(message, all, is_global):
126
- """
127
- Push commands to git store.
128
-
129
- By default pushes local commands (if in git repo), use --global/-g for global commands.
130
- """
131
- try:
132
- store_path = _get_store_path()
133
- from mcli.lib.paths import get_custom_commands_dir
134
-
135
- COMMANDS_PATH = get_custom_commands_dir(global_mode=is_global)
136
-
137
- # Copy commands to store
138
- info(f"Copying commands from {COMMANDS_PATH} to {store_path}...")
139
-
140
- copied_count = 0
141
- for item in COMMANDS_PATH.glob("*"):
142
- # Skip backups unless --all specified
143
- if not all and item.name.endswith(".backup"):
144
- continue
145
-
146
- dest = store_path / item.name
147
- if item.is_file():
148
- shutil.copy2(item, dest)
149
- copied_count += 1
150
- elif item.is_dir():
151
- shutil.copytree(item, dest, dirs_exist_ok=True)
152
- copied_count += 1
153
-
154
- success(f"Copied {copied_count} items to store")
155
-
156
- # Git add, commit, push
157
- subprocess.run(["git", "add", "."], cwd=store_path, check=True)
158
-
159
- # Check if there are changes
160
- result = subprocess.run(
161
- ["git", "status", "--porcelain"], cwd=store_path, capture_output=True, text=True
162
- )
163
-
164
- if not result.stdout.strip():
165
- info("No changes to commit")
166
- return
167
-
168
- # Commit with message
169
- commit_msg = message or f"Update commands {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
170
- subprocess.run(["git", "commit", "-m", commit_msg], cwd=store_path, check=True)
171
- success(f"Committed changes: {commit_msg}")
172
-
173
- # Push to remote if configured
174
- try:
175
- subprocess.run(["git", "push"], cwd=store_path, check=True, capture_output=True)
176
- success("Pushed to remote")
177
- except subprocess.CalledProcessError:
178
- warning("No remote configured or push failed. Commands committed locally.")
179
-
180
- except Exception as e:
181
- error(f"Failed to push commands: {e}")
182
- logger.exception(e)
183
-
184
-
185
- @store.command(name="pull")
186
- @click.option("--force", "-f", is_flag=True, help="Overwrite local commands without backup")
187
- @click.option(
188
- "--global", "-g", "is_global", is_flag=True, help="Pull to global commands instead of local"
189
- )
190
- def pull_commands(force, is_global):
191
- """
192
- Pull commands from git store.
193
-
194
- By default pulls to local commands (if in git repo), use --global/-g for global commands.
195
- """
196
- try:
197
- store_path = _get_store_path()
198
- from mcli.lib.paths import get_custom_commands_dir
199
-
200
- COMMANDS_PATH = get_custom_commands_dir(global_mode=is_global)
201
-
202
- # Pull from remote
203
- try:
204
- subprocess.run(["git", "pull"], cwd=store_path, check=True)
205
- success("Pulled latest changes from remote")
206
- except subprocess.CalledProcessError:
207
- warning("No remote configured or pull failed. Using local store.")
208
-
209
- # Backup existing commands if not force
210
- if not force and COMMANDS_PATH.exists():
211
- backup_dir = (
212
- COMMANDS_PATH.parent / f"commands_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
213
- )
214
- shutil.copytree(COMMANDS_PATH, backup_dir)
215
- info(f"Backed up existing commands to {backup_dir}")
216
-
217
- # Copy from store to commands directory
218
- info(f"Copying commands from {store_path} to {COMMANDS_PATH}...")
219
-
220
- COMMANDS_PATH.mkdir(parents=True, exist_ok=True)
221
-
222
- copied_count = 0
223
- for item in store_path.glob("*"):
224
- # Skip git directory and README
225
- if item.name in [".git", "README.md", ".gitignore"]:
226
- continue
227
-
228
- dest = COMMANDS_PATH / item.name
229
- if item.is_file():
230
- shutil.copy2(item, dest)
231
- copied_count += 1
232
- elif item.is_dir():
233
- shutil.copytree(item, dest, dirs_exist_ok=True)
234
- copied_count += 1
235
-
236
- success(f"Pulled {copied_count} items from store")
237
-
238
- except Exception as e:
239
- error(f"Failed to pull commands: {e}")
240
- logger.exception(e)
241
-
242
-
243
- @store.command(name="sync")
244
- @click.option("--message", "-m", help="Commit message if pushing")
245
- @click.option(
246
- "--global", "-g", "is_global", is_flag=True, help="Sync global commands instead of local"
247
- )
248
- def sync_commands(message, is_global):
249
- """
250
- Sync commands bidirectionally (pull then push if changes).
251
-
252
- By default syncs local commands (if in git repo), use --global/-g for global commands.
253
- """
254
- try:
255
- store_path = _get_store_path()
256
- from mcli.lib.paths import get_custom_commands_dir
257
-
258
- COMMANDS_PATH = get_custom_commands_dir(global_mode=is_global)
259
-
260
- # First pull
261
- info("Pulling latest changes...")
262
- try:
263
- subprocess.run(["git", "pull"], cwd=store_path, check=True, capture_output=True)
264
- success("Pulled from remote")
265
- except subprocess.CalledProcessError:
266
- warning("No remote or pull failed")
267
-
268
- # Then push local changes
269
- info("Pushing local changes...")
270
-
271
- # Copy commands
272
- for item in COMMANDS_PATH.glob("*"):
273
- if item.name.endswith(".backup"):
274
- continue
275
- dest = store_path / item.name
276
- if item.is_file():
277
- shutil.copy2(item, dest)
278
- elif item.is_dir():
279
- shutil.copytree(item, dest, dirs_exist_ok=True)
280
-
281
- # Check for changes
282
- result = subprocess.run(
283
- ["git", "status", "--porcelain"], cwd=store_path, capture_output=True, text=True
284
- )
285
-
286
- if not result.stdout.strip():
287
- success("Everything in sync!")
288
- return
289
-
290
- # Commit and push
291
- subprocess.run(["git", "add", "."], cwd=store_path, check=True)
292
- commit_msg = message or f"Sync commands {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
293
- subprocess.run(["git", "commit", "-m", commit_msg], cwd=store_path, check=True)
294
-
295
- try:
296
- subprocess.run(["git", "push"], cwd=store_path, check=True, capture_output=True)
297
- success("Synced and pushed to remote")
298
- except subprocess.CalledProcessError:
299
- success("Synced locally (no remote configured)")
300
-
301
- except Exception as e:
302
- error(f"Sync failed: {e}")
303
- logger.exception(e)
304
-
305
-
306
- @store.command(name="status")
307
- def store_status():
308
- """Show git status of command store."""
309
- try:
310
- store_path = _get_store_path()
311
-
312
- click.echo(f"\n📦 Store: {store_path}\n")
313
-
314
- # Git status
315
- result = subprocess.run(
316
- ["git", "status", "--short", "--branch"], cwd=store_path, capture_output=True, text=True
317
- )
318
-
319
- if result.stdout:
320
- click.echo(result.stdout)
321
-
322
- # Show remote
323
- result = subprocess.run(
324
- ["git", "remote", "-v"], cwd=store_path, capture_output=True, text=True
325
- )
326
-
327
- if result.stdout:
328
- click.echo("\n🌐 Remotes:")
329
- click.echo(result.stdout)
330
- else:
331
- info("\nNo remote configured")
332
-
333
- click.echo()
334
-
335
- except Exception as e:
336
- error(f"Failed to get status: {e}")
337
- logger.exception(e)
338
-
339
-
340
- @store.command(name="config")
341
- @click.option("--remote", "-r", help="Set git remote URL")
342
- @click.option("--path", "-p", type=click.Path(), help="Change store path")
343
- def configure_store(remote, path):
344
- """Configure store settings."""
345
- try:
346
- store_path = _get_store_path()
347
-
348
- if path:
349
- new_path = Path(path).expanduser().resolve()
350
- config_file = Path.home() / ".mcli" / "store.conf"
351
- config_file.write_text(str(new_path))
352
- success(f"Store path updated to: {new_path}")
353
- return
354
-
355
- if remote:
356
- # Check if remote exists
357
- result = subprocess.run(
358
- ["git", "remote"], cwd=store_path, capture_output=True, text=True
359
- )
360
-
361
- if "origin" in result.stdout:
362
- subprocess.run(
363
- ["git", "remote", "set-url", "origin", remote], cwd=store_path, check=True
364
- )
365
- success(f"Updated remote URL: {remote}")
366
- else:
367
- subprocess.run(
368
- ["git", "remote", "add", "origin", remote], cwd=store_path, check=True
369
- )
370
- success(f"Added remote URL: {remote}")
371
-
372
- except Exception as e:
373
- error(f"Configuration failed: {e}")
374
- logger.exception(e)
375
-
376
-
377
- @store.command(name="list")
378
- @click.option("--store-dir", "-s", is_flag=True, help="List store instead of local")
379
- def list_commands(store_dir):
380
- """List all commands."""
381
- try:
382
- if store_dir:
383
- store_path = _get_store_path()
384
- path = store_path
385
- title = f"Commands in store ({store_path})"
386
- else:
387
- path = COMMANDS_PATH
388
- title = f"Local commands ({COMMANDS_PATH})"
389
-
390
- click.echo(f"\n{title}:\n")
391
-
392
- if not path.exists():
393
- warning(f"Directory does not exist: {path}")
394
- return
395
-
396
- items = sorted(path.glob("*"))
397
- if not items:
398
- info("No commands found")
399
- return
400
-
401
- for item in items:
402
- if item.name in [".git", ".gitignore", "README.md"]:
403
- continue
404
-
405
- if item.is_file():
406
- size = item.stat().st_size / 1024
407
- modified = datetime.fromtimestamp(item.stat().st_mtime).strftime("%Y-%m-%d %H:%M")
408
- click.echo(f" 📄 {item.name:<40} {size:>8.1f} KB {modified}")
409
- elif item.is_dir():
410
- count = len(list(item.glob("*")))
411
- click.echo(f" 📁 {item.name:<40} {count:>3} files")
412
-
413
- click.echo()
414
-
415
- except Exception as e:
416
- error(f"Failed to list commands: {e}")
417
- logger.exception(e)
418
-
419
-
420
- @store.command(name="show")
421
- @click.argument("command_name")
422
- @click.option("--store-dir", "-s", is_flag=True, help="Show from store instead of local")
423
- def show_command(command_name, store_dir):
424
- """Show command file contents."""
425
- try:
426
- if store_dir:
427
- store_path = _get_store_path()
428
- path = store_path / command_name
429
- else:
430
- path = COMMANDS_PATH / command_name
431
-
432
- if not path.exists():
433
- error(f"Command not found: {command_name}")
434
- return
435
-
436
- if path.is_file():
437
- click.echo(f"\n📄 {path}:\n")
438
- click.echo(path.read_text())
439
- else:
440
- info(f"{command_name} is a directory")
441
- for item in sorted(path.glob("*")):
442
- click.echo(f" {item.name}")
443
-
444
- click.echo()
445
-
446
- except Exception as e:
447
- error(f"Failed to show command: {e}")
448
- logger.exception(e)