mcli-framework 7.8.5__py3-none-any.whl → 7.9.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/main.py +9 -0
- mcli/lib/lib.py +8 -1
- mcli/lib/secrets/__init__.py +10 -0
- mcli/lib/secrets/commands.py +185 -0
- mcli/lib/secrets/manager.py +213 -0
- mcli/lib/secrets/repl.py +297 -0
- mcli/lib/secrets/store.py +246 -0
- mcli/self/self_cmd.py +8 -0
- mcli/self/zsh_cmd.py +259 -0
- {mcli_framework-7.8.5.dist-info → mcli_framework-7.9.0.dist-info}/METADATA +2 -1
- {mcli_framework-7.8.5.dist-info → mcli_framework-7.9.0.dist-info}/RECORD +15 -9
- {mcli_framework-7.8.5.dist-info → mcli_framework-7.9.0.dist-info}/WHEEL +0 -0
- {mcli_framework-7.8.5.dist-info → mcli_framework-7.9.0.dist-info}/entry_points.txt +0 -0
- {mcli_framework-7.8.5.dist-info → mcli_framework-7.9.0.dist-info}/licenses/LICENSE +0 -0
- {mcli_framework-7.8.5.dist-info → mcli_framework-7.9.0.dist-info}/top_level.txt +0 -0
mcli/app/main.py
CHANGED
|
@@ -344,6 +344,15 @@ def _add_lazy_commands(app: click.Group):
|
|
|
344
344
|
except Exception as e:
|
|
345
345
|
logger.debug(f"Could not load self commands: {e}")
|
|
346
346
|
|
|
347
|
+
# Library utilities and secrets management
|
|
348
|
+
try:
|
|
349
|
+
from mcli.lib.lib import lib
|
|
350
|
+
|
|
351
|
+
app.add_command(lib, name="lib")
|
|
352
|
+
logger.debug("Added lib commands")
|
|
353
|
+
except Exception as e:
|
|
354
|
+
logger.debug(f"Could not load lib commands: {e}")
|
|
355
|
+
|
|
347
356
|
# Add workflow with completion-aware lazy loading
|
|
348
357
|
try:
|
|
349
358
|
from mcli.app.completion_helpers import create_completion_aware_lazy_group
|
mcli/lib/lib.py
CHANGED
|
@@ -3,6 +3,8 @@ import sys
|
|
|
3
3
|
|
|
4
4
|
import click
|
|
5
5
|
|
|
6
|
+
from mcli.lib.secrets.commands import secrets_group
|
|
7
|
+
|
|
6
8
|
|
|
7
9
|
def import_public_module(module_name: str):
|
|
8
10
|
prefix = "mcli.public."
|
|
@@ -20,10 +22,15 @@ def import_public_module(module_name: str):
|
|
|
20
22
|
return module
|
|
21
23
|
|
|
22
24
|
|
|
23
|
-
@click.group(name="lib")
|
|
25
|
+
@click.group(name="lib", help="Library utilities and secrets management")
|
|
24
26
|
def lib():
|
|
27
|
+
"""Library utilities and management commands."""
|
|
25
28
|
pass
|
|
26
29
|
|
|
27
30
|
|
|
31
|
+
# Add secrets as a subcommand
|
|
32
|
+
lib.add_command(secrets_group)
|
|
33
|
+
|
|
34
|
+
|
|
28
35
|
if __name__ == "__main__":
|
|
29
36
|
lib()
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI commands for secrets management.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from mcli.lib.ui.styling import error, info, success, warning
|
|
11
|
+
|
|
12
|
+
from .manager import SecretsManager
|
|
13
|
+
from .repl import run_repl
|
|
14
|
+
from .store import SecretsStore
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@click.group(name="secrets", help="Secure secrets management with encryption and git sync")
|
|
18
|
+
def secrets_group():
|
|
19
|
+
"""Secrets management commands."""
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@secrets_group.command(name="repl", help="Launch interactive secrets shell")
|
|
24
|
+
def secrets_repl():
|
|
25
|
+
"""Launch the interactive secrets REPL."""
|
|
26
|
+
run_repl()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@secrets_group.command(name="set", help="Set a secret value")
|
|
30
|
+
@click.argument("key")
|
|
31
|
+
@click.argument("value")
|
|
32
|
+
@click.option("-n", "--namespace", default="default", help="Namespace for the secret")
|
|
33
|
+
def secrets_set(key: str, value: str, namespace: str):
|
|
34
|
+
"""Set a secret value."""
|
|
35
|
+
manager = SecretsManager()
|
|
36
|
+
try:
|
|
37
|
+
manager.set(key, value, namespace)
|
|
38
|
+
success(f"Secret '{key}' set in namespace '{namespace}'")
|
|
39
|
+
except Exception as e:
|
|
40
|
+
error(f"Failed to set secret: {e}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@secrets_group.command(name="get", help="Get a secret value")
|
|
44
|
+
@click.argument("key")
|
|
45
|
+
@click.option("-n", "--namespace", default="default", help="Namespace for the secret")
|
|
46
|
+
@click.option("-s", "--show", is_flag=True, help="Show the full value (not masked)")
|
|
47
|
+
def secrets_get(key: str, namespace: str, show: bool):
|
|
48
|
+
"""Get a secret value."""
|
|
49
|
+
manager = SecretsManager()
|
|
50
|
+
value = manager.get(key, namespace)
|
|
51
|
+
|
|
52
|
+
if value is not None:
|
|
53
|
+
if show:
|
|
54
|
+
click.echo(value)
|
|
55
|
+
else:
|
|
56
|
+
# Mask the value
|
|
57
|
+
masked = (
|
|
58
|
+
value[:3] + "*" * (len(value) - 6) + value[-3:]
|
|
59
|
+
if len(value) > 6
|
|
60
|
+
else "*" * len(value)
|
|
61
|
+
)
|
|
62
|
+
info(f"{key} = {masked}")
|
|
63
|
+
info("Use --show to display the full value")
|
|
64
|
+
else:
|
|
65
|
+
warning(f"Secret '{key}' not found in namespace '{namespace}'")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@secrets_group.command(name="list", help="List all secrets")
|
|
69
|
+
@click.option("-n", "--namespace", help="Filter by namespace")
|
|
70
|
+
def secrets_list(namespace: Optional[str]):
|
|
71
|
+
"""List all secrets."""
|
|
72
|
+
manager = SecretsManager()
|
|
73
|
+
secrets = manager.list(namespace)
|
|
74
|
+
|
|
75
|
+
if secrets:
|
|
76
|
+
info("Secrets:")
|
|
77
|
+
for secret in secrets:
|
|
78
|
+
click.echo(f" • {secret}")
|
|
79
|
+
else:
|
|
80
|
+
info("No secrets found")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@secrets_group.command(name="delete", help="Delete a secret")
|
|
84
|
+
@click.argument("key")
|
|
85
|
+
@click.option("-n", "--namespace", default="default", help="Namespace for the secret")
|
|
86
|
+
@click.confirmation_option(prompt="Are you sure you want to delete this secret?")
|
|
87
|
+
def secrets_delete(key: str, namespace: str):
|
|
88
|
+
"""Delete a secret."""
|
|
89
|
+
manager = SecretsManager()
|
|
90
|
+
if manager.delete(key, namespace):
|
|
91
|
+
success(f"Secret '{key}' deleted from namespace '{namespace}'")
|
|
92
|
+
else:
|
|
93
|
+
warning(f"Secret '{key}' not found in namespace '{namespace}'")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@secrets_group.command(name="export", help="Export secrets as environment variables")
|
|
97
|
+
@click.option("-n", "--namespace", help="Namespace to export")
|
|
98
|
+
@click.option("-o", "--output", type=click.Path(), help="Output file (defaults to stdout)")
|
|
99
|
+
def secrets_export(namespace: Optional[str], output: Optional[str]):
|
|
100
|
+
"""Export secrets as environment variables."""
|
|
101
|
+
manager = SecretsManager()
|
|
102
|
+
env_vars = manager.export_env(namespace)
|
|
103
|
+
|
|
104
|
+
if env_vars:
|
|
105
|
+
if output:
|
|
106
|
+
with open(output, "w") as f:
|
|
107
|
+
for key, value in env_vars.items():
|
|
108
|
+
f.write(f"export {key}={value}\n")
|
|
109
|
+
success(f"Exported {len(env_vars)} secrets to {output}")
|
|
110
|
+
else:
|
|
111
|
+
for key, value in env_vars.items():
|
|
112
|
+
click.echo(f"export {key}={value}")
|
|
113
|
+
else:
|
|
114
|
+
info("No secrets to export")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@secrets_group.command(name="import", help="Import secrets from environment file")
|
|
118
|
+
@click.argument("env_file", type=click.Path(exists=True))
|
|
119
|
+
@click.option("-n", "--namespace", default="default", help="Namespace to import into")
|
|
120
|
+
def secrets_import(env_file: str, namespace: str):
|
|
121
|
+
"""Import secrets from environment file."""
|
|
122
|
+
manager = SecretsManager()
|
|
123
|
+
count = manager.import_env(Path(env_file), namespace)
|
|
124
|
+
success(f"Imported {count} secrets into namespace '{namespace}'")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@secrets_group.group(name="store", help="Git-based secrets synchronization")
|
|
128
|
+
def store_group():
|
|
129
|
+
"""Store management commands."""
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@store_group.command(name="init", help="Initialize secrets store")
|
|
134
|
+
@click.option("-r", "--remote", help="Git remote URL")
|
|
135
|
+
def store_init(remote: Optional[str]):
|
|
136
|
+
"""Initialize the secrets store."""
|
|
137
|
+
store = SecretsStore()
|
|
138
|
+
store.init(remote)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@store_group.command(name="push", help="Push secrets to store")
|
|
142
|
+
@click.option("-m", "--message", help="Commit message")
|
|
143
|
+
def store_push(message: Optional[str]):
|
|
144
|
+
"""Push secrets to store."""
|
|
145
|
+
manager = SecretsManager()
|
|
146
|
+
store = SecretsStore()
|
|
147
|
+
store.push(manager.secrets_dir, message)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@store_group.command(name="pull", help="Pull secrets from store")
|
|
151
|
+
def store_pull():
|
|
152
|
+
"""Pull secrets from store."""
|
|
153
|
+
manager = SecretsManager()
|
|
154
|
+
store = SecretsStore()
|
|
155
|
+
store.pull(manager.secrets_dir)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@store_group.command(name="sync", help="Sync secrets with store")
|
|
159
|
+
@click.option("-m", "--message", help="Commit message")
|
|
160
|
+
def store_sync(message: Optional[str]):
|
|
161
|
+
"""Sync secrets with store."""
|
|
162
|
+
manager = SecretsManager()
|
|
163
|
+
store = SecretsStore()
|
|
164
|
+
store.sync(manager.secrets_dir, message)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@store_group.command(name="status", help="Show store status")
|
|
168
|
+
def store_status():
|
|
169
|
+
"""Show store status."""
|
|
170
|
+
store = SecretsStore()
|
|
171
|
+
status = store.status()
|
|
172
|
+
|
|
173
|
+
info("Secrets Store Status:")
|
|
174
|
+
click.echo(f" Initialized: {status['initialized']}")
|
|
175
|
+
click.echo(f" Path: {status['store_path']}")
|
|
176
|
+
|
|
177
|
+
if status["initialized"]:
|
|
178
|
+
click.echo(f" Branch: {status['branch']}")
|
|
179
|
+
click.echo(f" Commit: {status['commit']}")
|
|
180
|
+
click.echo(f" Clean: {status['clean']}")
|
|
181
|
+
|
|
182
|
+
if status["has_remote"]:
|
|
183
|
+
click.echo(f" Remote: {status['remote_url']}")
|
|
184
|
+
else:
|
|
185
|
+
click.echo(" Remote: Not configured")
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Secrets manager for handling secure storage and retrieval of secrets.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
from cryptography.fernet import Fernet
|
|
13
|
+
from cryptography.hazmat.primitives import hashes
|
|
14
|
+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
15
|
+
|
|
16
|
+
from mcli.lib.logger.logger import get_logger
|
|
17
|
+
|
|
18
|
+
logger = get_logger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SecretsManager:
|
|
22
|
+
"""Manages secrets storage with encryption."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, secrets_dir: Optional[Path] = None):
|
|
25
|
+
"""Initialize the secrets manager.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
secrets_dir: Directory to store secrets. Defaults to ~/.mcli/secrets/
|
|
29
|
+
"""
|
|
30
|
+
self.secrets_dir = secrets_dir or Path.home() / ".mcli" / "secrets"
|
|
31
|
+
self.secrets_dir.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
self._cipher_suite = self._get_cipher_suite()
|
|
33
|
+
|
|
34
|
+
def _get_cipher_suite(self) -> Fernet:
|
|
35
|
+
"""Get or create encryption key."""
|
|
36
|
+
key_file = self.secrets_dir / ".key"
|
|
37
|
+
|
|
38
|
+
if key_file.exists():
|
|
39
|
+
with open(key_file, "rb") as f:
|
|
40
|
+
key = f.read()
|
|
41
|
+
else:
|
|
42
|
+
# Generate a new key from a password
|
|
43
|
+
password = click.prompt("Enter a password for secrets encryption", hide_input=True)
|
|
44
|
+
password_bytes = password.encode()
|
|
45
|
+
|
|
46
|
+
# Use PBKDF2 to derive a key from the password
|
|
47
|
+
kdf = PBKDF2HMAC(
|
|
48
|
+
algorithm=hashes.SHA256(),
|
|
49
|
+
length=32,
|
|
50
|
+
salt=b"mcli-secrets-salt", # In production, use a random salt
|
|
51
|
+
iterations=100000,
|
|
52
|
+
)
|
|
53
|
+
key = base64.urlsafe_b64encode(kdf.derive(password_bytes))
|
|
54
|
+
|
|
55
|
+
# Save the key (in production, this should be stored more securely)
|
|
56
|
+
with open(key_file, "wb") as f:
|
|
57
|
+
f.write(key)
|
|
58
|
+
|
|
59
|
+
# Set restrictive permissions
|
|
60
|
+
os.chmod(key_file, 0o600)
|
|
61
|
+
|
|
62
|
+
return Fernet(key)
|
|
63
|
+
|
|
64
|
+
def set(self, key: str, value: str, namespace: Optional[str] = None) -> None:
|
|
65
|
+
"""Set a secret value.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
key: Secret key
|
|
69
|
+
value: Secret value
|
|
70
|
+
namespace: Optional namespace for grouping secrets
|
|
71
|
+
"""
|
|
72
|
+
namespace = namespace or "default"
|
|
73
|
+
namespace_dir = self.secrets_dir / namespace
|
|
74
|
+
namespace_dir.mkdir(exist_ok=True)
|
|
75
|
+
|
|
76
|
+
# Encrypt the value
|
|
77
|
+
encrypted_value = self._cipher_suite.encrypt(value.encode())
|
|
78
|
+
|
|
79
|
+
# Store the encrypted value
|
|
80
|
+
secret_file = namespace_dir / f"{key}.secret"
|
|
81
|
+
with open(secret_file, "wb") as f:
|
|
82
|
+
f.write(encrypted_value)
|
|
83
|
+
|
|
84
|
+
# Set restrictive permissions
|
|
85
|
+
os.chmod(secret_file, 0o600)
|
|
86
|
+
|
|
87
|
+
logger.debug(f"Secret '{key}' stored in namespace '{namespace}'")
|
|
88
|
+
|
|
89
|
+
def get(self, key: str, namespace: Optional[str] = None) -> Optional[str]:
|
|
90
|
+
"""Get a secret value.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
key: Secret key
|
|
94
|
+
namespace: Optional namespace
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Decrypted secret value or None if not found
|
|
98
|
+
"""
|
|
99
|
+
namespace = namespace or "default"
|
|
100
|
+
secret_file = self.secrets_dir / namespace / f"{key}.secret"
|
|
101
|
+
|
|
102
|
+
if not secret_file.exists():
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
with open(secret_file, "rb") as f:
|
|
106
|
+
encrypted_value = f.read()
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
decrypted_value = self._cipher_suite.decrypt(encrypted_value)
|
|
110
|
+
return decrypted_value.decode()
|
|
111
|
+
except Exception as e:
|
|
112
|
+
logger.error(f"Failed to decrypt secret '{key}': {e}")
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
def list(self, namespace: Optional[str] = None) -> List[str]:
|
|
116
|
+
"""List all secret keys.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
namespace: Optional namespace filter
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
List of secret keys
|
|
123
|
+
"""
|
|
124
|
+
if namespace:
|
|
125
|
+
namespace_dirs = [self.secrets_dir / namespace]
|
|
126
|
+
else:
|
|
127
|
+
namespace_dirs = [
|
|
128
|
+
d for d in self.secrets_dir.iterdir() if d.is_dir() and not d.name.startswith(".")
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
secrets = []
|
|
132
|
+
for namespace_dir in namespace_dirs:
|
|
133
|
+
if namespace_dir.exists():
|
|
134
|
+
for secret_file in namespace_dir.glob("*.secret"):
|
|
135
|
+
key = secret_file.stem
|
|
136
|
+
ns = namespace_dir.name
|
|
137
|
+
secrets.append(f"{ns}/{key}" if not namespace else key)
|
|
138
|
+
|
|
139
|
+
return sorted(secrets)
|
|
140
|
+
|
|
141
|
+
def delete(self, key: str, namespace: Optional[str] = None) -> bool:
|
|
142
|
+
"""Delete a secret.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
key: Secret key
|
|
146
|
+
namespace: Optional namespace
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
True if deleted, False if not found
|
|
150
|
+
"""
|
|
151
|
+
namespace = namespace or "default"
|
|
152
|
+
secret_file = self.secrets_dir / namespace / f"{key}.secret"
|
|
153
|
+
|
|
154
|
+
if secret_file.exists():
|
|
155
|
+
secret_file.unlink()
|
|
156
|
+
logger.debug(f"Secret '{key}' deleted from namespace '{namespace}'")
|
|
157
|
+
return True
|
|
158
|
+
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
def export_env(self, namespace: Optional[str] = None) -> Dict[str, str]:
|
|
162
|
+
"""Export secrets as environment variables.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
namespace: Optional namespace filter
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
Dictionary of key-value pairs
|
|
169
|
+
"""
|
|
170
|
+
env_vars = {}
|
|
171
|
+
|
|
172
|
+
for secret_key in self.list(namespace):
|
|
173
|
+
if "/" in secret_key:
|
|
174
|
+
ns, key = secret_key.split("/", 1)
|
|
175
|
+
value = self.get(key, ns)
|
|
176
|
+
else:
|
|
177
|
+
value = self.get(secret_key, namespace)
|
|
178
|
+
|
|
179
|
+
if value:
|
|
180
|
+
# Convert to uppercase for environment variable convention
|
|
181
|
+
if "/" in secret_key:
|
|
182
|
+
env_key = key.upper().replace("-", "_")
|
|
183
|
+
else:
|
|
184
|
+
env_key = secret_key.upper().replace("-", "_")
|
|
185
|
+
env_vars[env_key] = value
|
|
186
|
+
|
|
187
|
+
return env_vars
|
|
188
|
+
|
|
189
|
+
def import_env(self, env_file: Path, namespace: Optional[str] = None) -> int:
|
|
190
|
+
"""Import secrets from an environment file.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
env_file: Path to .env file
|
|
194
|
+
namespace: Optional namespace
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
Number of secrets imported
|
|
198
|
+
"""
|
|
199
|
+
namespace = namespace or "default"
|
|
200
|
+
count = 0
|
|
201
|
+
|
|
202
|
+
with open(env_file) as f:
|
|
203
|
+
for line in f:
|
|
204
|
+
line = line.strip()
|
|
205
|
+
if line and not line.startswith("#") and "=" in line:
|
|
206
|
+
key, value = line.split("=", 1)
|
|
207
|
+
key = key.strip()
|
|
208
|
+
value = value.strip().strip('"').strip("'")
|
|
209
|
+
|
|
210
|
+
self.set(key.lower().replace("_", "-"), value, namespace)
|
|
211
|
+
count += 1
|
|
212
|
+
|
|
213
|
+
return count
|
mcli/lib/secrets/repl.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""
|
|
2
|
+
REPL (Read-Eval-Print Loop) for LSH secrets management.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import List
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
from prompt_toolkit import prompt
|
|
11
|
+
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
|
12
|
+
from prompt_toolkit.completion import WordCompleter
|
|
13
|
+
from prompt_toolkit.history import FileHistory
|
|
14
|
+
|
|
15
|
+
from mcli.lib.logger.logger import get_logger
|
|
16
|
+
|
|
17
|
+
logger = get_logger(__name__)
|
|
18
|
+
from mcli.lib.ui.styling import console, error, info, success, warning
|
|
19
|
+
|
|
20
|
+
from .manager import SecretsManager
|
|
21
|
+
from .store import SecretsStore
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SecretsREPL:
|
|
25
|
+
"""Interactive REPL for secrets management."""
|
|
26
|
+
|
|
27
|
+
def __init__(self):
|
|
28
|
+
"""Initialize the REPL."""
|
|
29
|
+
self.manager = SecretsManager()
|
|
30
|
+
self.store = SecretsStore()
|
|
31
|
+
self.running = False
|
|
32
|
+
self.namespace = "default"
|
|
33
|
+
self.history_file = Path.home() / ".mcli" / "secrets_repl_history"
|
|
34
|
+
|
|
35
|
+
# Commands
|
|
36
|
+
self.commands = {
|
|
37
|
+
"set": self.cmd_set,
|
|
38
|
+
"get": self.cmd_get,
|
|
39
|
+
"list": self.cmd_list,
|
|
40
|
+
"delete": self.cmd_delete,
|
|
41
|
+
"namespace": self.cmd_namespace,
|
|
42
|
+
"export": self.cmd_export,
|
|
43
|
+
"import": self.cmd_import,
|
|
44
|
+
"push": self.cmd_push,
|
|
45
|
+
"pull": self.cmd_pull,
|
|
46
|
+
"sync": self.cmd_sync,
|
|
47
|
+
"status": self.cmd_status,
|
|
48
|
+
"help": self.cmd_help,
|
|
49
|
+
"exit": self.cmd_exit,
|
|
50
|
+
"quit": self.cmd_exit,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# Command completer
|
|
54
|
+
self.completer = WordCompleter(list(self.commands.keys()) + ["ns"], ignore_case=True)
|
|
55
|
+
|
|
56
|
+
def run(self):
|
|
57
|
+
"""Run the REPL."""
|
|
58
|
+
self.running = True
|
|
59
|
+
|
|
60
|
+
# Print welcome message
|
|
61
|
+
console.print("[bold cyan]MCLI Secrets Management Shell[/bold cyan]")
|
|
62
|
+
console.print("Type 'help' for available commands or 'exit' to quit.\n")
|
|
63
|
+
|
|
64
|
+
# Create history file directory if needed
|
|
65
|
+
self.history_file.parent.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
|
|
67
|
+
while self.running:
|
|
68
|
+
try:
|
|
69
|
+
# Build prompt
|
|
70
|
+
prompt_text = f"[{self.namespace}]> "
|
|
71
|
+
|
|
72
|
+
# Get user input
|
|
73
|
+
user_input = prompt(
|
|
74
|
+
prompt_text,
|
|
75
|
+
completer=self.completer,
|
|
76
|
+
history=FileHistory(str(self.history_file)),
|
|
77
|
+
auto_suggest=AutoSuggestFromHistory(),
|
|
78
|
+
).strip()
|
|
79
|
+
|
|
80
|
+
if not user_input:
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
# Parse command and arguments
|
|
84
|
+
parts = user_input.split()
|
|
85
|
+
command = parts[0].lower()
|
|
86
|
+
args = parts[1:] if len(parts) > 1 else []
|
|
87
|
+
|
|
88
|
+
# Handle command aliases
|
|
89
|
+
if command == "ns":
|
|
90
|
+
command = "namespace"
|
|
91
|
+
|
|
92
|
+
# Execute command
|
|
93
|
+
if command in self.commands:
|
|
94
|
+
self.commands[command](args)
|
|
95
|
+
else:
|
|
96
|
+
error(f"Unknown command: {command}")
|
|
97
|
+
console.print("Type 'help' for available commands.")
|
|
98
|
+
|
|
99
|
+
except KeyboardInterrupt:
|
|
100
|
+
console.print("\nUse 'exit' or 'quit' to leave the shell.")
|
|
101
|
+
except EOFError:
|
|
102
|
+
self.cmd_exit([])
|
|
103
|
+
except Exception as e:
|
|
104
|
+
error(f"Error: {e}")
|
|
105
|
+
logger.exception("REPL error")
|
|
106
|
+
|
|
107
|
+
def cmd_set(self, args: List[str]):
|
|
108
|
+
"""Set a secret value."""
|
|
109
|
+
if len(args) < 2:
|
|
110
|
+
error("Usage: set <key> <value>")
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
key = args[0]
|
|
114
|
+
value = " ".join(args[1:])
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
self.manager.set(key, value, self.namespace)
|
|
118
|
+
success(f"Secret '{key}' set in namespace '{self.namespace}'")
|
|
119
|
+
except Exception as e:
|
|
120
|
+
error(f"Failed to set secret: {e}")
|
|
121
|
+
|
|
122
|
+
def cmd_get(self, args: List[str]):
|
|
123
|
+
"""Get a secret value."""
|
|
124
|
+
if len(args) != 1:
|
|
125
|
+
error("Usage: get <key>")
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
key = args[0]
|
|
129
|
+
value = self.manager.get(key, self.namespace)
|
|
130
|
+
|
|
131
|
+
if value is not None:
|
|
132
|
+
# Mask the value for security
|
|
133
|
+
masked_value = (
|
|
134
|
+
value[:3] + "*" * (len(value) - 6) + value[-3:]
|
|
135
|
+
if len(value) > 6
|
|
136
|
+
else "*" * len(value)
|
|
137
|
+
)
|
|
138
|
+
info(f"{key} = {masked_value}")
|
|
139
|
+
|
|
140
|
+
if click.confirm("Show full value?", default=False):
|
|
141
|
+
console.print(f"[yellow]{value}[/yellow]")
|
|
142
|
+
else:
|
|
143
|
+
warning(f"Secret '{key}' not found in namespace '{self.namespace}'")
|
|
144
|
+
|
|
145
|
+
def cmd_list(self, args: List[str]):
|
|
146
|
+
"""List all secrets."""
|
|
147
|
+
secrets = self.manager.list(self.namespace if args != ["all"] else None)
|
|
148
|
+
|
|
149
|
+
if secrets:
|
|
150
|
+
console.print("[bold]Secrets:[/bold]")
|
|
151
|
+
for secret in secrets:
|
|
152
|
+
console.print(f" • {secret}")
|
|
153
|
+
else:
|
|
154
|
+
info("No secrets found")
|
|
155
|
+
|
|
156
|
+
def cmd_delete(self, args: List[str]):
|
|
157
|
+
"""Delete a secret."""
|
|
158
|
+
if len(args) != 1:
|
|
159
|
+
error("Usage: delete <key>")
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
key = args[0]
|
|
163
|
+
|
|
164
|
+
if click.confirm(f"Delete secret '{key}' from namespace '{self.namespace}'?"):
|
|
165
|
+
if self.manager.delete(key, self.namespace):
|
|
166
|
+
success(f"Secret '{key}' deleted")
|
|
167
|
+
else:
|
|
168
|
+
warning(f"Secret '{key}' not found")
|
|
169
|
+
|
|
170
|
+
def cmd_namespace(self, args: List[str]):
|
|
171
|
+
"""Switch namespace."""
|
|
172
|
+
if len(args) == 0:
|
|
173
|
+
# List namespaces
|
|
174
|
+
namespaces = set()
|
|
175
|
+
for d in self.manager.secrets_dir.iterdir():
|
|
176
|
+
if d.is_dir() and not d.name.startswith("."):
|
|
177
|
+
namespaces.add(d.name)
|
|
178
|
+
|
|
179
|
+
console.print(f"[bold]Current namespace:[/bold] {self.namespace}")
|
|
180
|
+
if namespaces:
|
|
181
|
+
console.print("[bold]Available namespaces:[/bold]")
|
|
182
|
+
for ns in sorted(namespaces):
|
|
183
|
+
marker = "→" if ns == self.namespace else " "
|
|
184
|
+
console.print(f" {marker} {ns}")
|
|
185
|
+
elif len(args) == 1:
|
|
186
|
+
self.namespace = args[0]
|
|
187
|
+
success(f"Switched to namespace '{self.namespace}'")
|
|
188
|
+
else:
|
|
189
|
+
error("Usage: namespace [<name>]")
|
|
190
|
+
|
|
191
|
+
def cmd_export(self, args: List[str]):
|
|
192
|
+
"""Export secrets as environment variables."""
|
|
193
|
+
env_vars = self.manager.export_env(self.namespace)
|
|
194
|
+
|
|
195
|
+
if env_vars:
|
|
196
|
+
if args and args[0] == "file":
|
|
197
|
+
# Export to file
|
|
198
|
+
filename = args[1] if len(args) > 1 else f"{self.namespace}.env"
|
|
199
|
+
with open(filename, "w") as f:
|
|
200
|
+
for key, value in env_vars.items():
|
|
201
|
+
f.write(f"{key}={value}\n")
|
|
202
|
+
success(f"Exported {len(env_vars)} secrets to {filename}")
|
|
203
|
+
else:
|
|
204
|
+
# Display export commands
|
|
205
|
+
console.print("[bold]Export commands:[/bold]")
|
|
206
|
+
for key, value in env_vars.items():
|
|
207
|
+
masked_value = value[:3] + "***" + value[-3:] if len(value) > 6 else "***"
|
|
208
|
+
console.print(f"export {key}={masked_value}")
|
|
209
|
+
else:
|
|
210
|
+
info("No secrets to export")
|
|
211
|
+
|
|
212
|
+
def cmd_import(self, args: List[str]):
|
|
213
|
+
"""Import secrets from environment file."""
|
|
214
|
+
if len(args) != 1:
|
|
215
|
+
error("Usage: import <env-file>")
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
env_file = Path(args[0])
|
|
219
|
+
if not env_file.exists():
|
|
220
|
+
error(f"File not found: {env_file}")
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
count = self.manager.import_env(env_file, self.namespace)
|
|
224
|
+
success(f"Imported {count} secrets from {env_file}")
|
|
225
|
+
|
|
226
|
+
def cmd_push(self, args: List[str]):
|
|
227
|
+
"""Push secrets to git store."""
|
|
228
|
+
message = " ".join(args) if args else None
|
|
229
|
+
self.store.push(self.manager.secrets_dir, message)
|
|
230
|
+
|
|
231
|
+
def cmd_pull(self, args: List[str]):
|
|
232
|
+
"""Pull secrets from git store."""
|
|
233
|
+
self.store.pull(self.manager.secrets_dir)
|
|
234
|
+
|
|
235
|
+
def cmd_sync(self, args: List[str]):
|
|
236
|
+
"""Sync secrets with git store."""
|
|
237
|
+
message = " ".join(args) if args else None
|
|
238
|
+
self.store.sync(self.manager.secrets_dir, message)
|
|
239
|
+
|
|
240
|
+
def cmd_status(self, args: List[str]):
|
|
241
|
+
"""Show store status."""
|
|
242
|
+
status = self.store.status()
|
|
243
|
+
|
|
244
|
+
console.print("[bold]Secrets Store Status:[/bold]")
|
|
245
|
+
console.print(f" Initialized: {status['initialized']}")
|
|
246
|
+
console.print(f" Path: {status['store_path']}")
|
|
247
|
+
|
|
248
|
+
if status["initialized"]:
|
|
249
|
+
console.print(f" Branch: {status['branch']}")
|
|
250
|
+
console.print(f" Commit: {status['commit']}")
|
|
251
|
+
console.print(f" Clean: {status['clean']}")
|
|
252
|
+
|
|
253
|
+
if status["has_remote"]:
|
|
254
|
+
console.print(f" Remote: {status['remote_url']}")
|
|
255
|
+
else:
|
|
256
|
+
console.print(" Remote: [dim]Not configured[/dim]")
|
|
257
|
+
|
|
258
|
+
def cmd_help(self, args: List[str]):
|
|
259
|
+
"""Show help information."""
|
|
260
|
+
console.print("[bold]Available Commands:[/bold]\n")
|
|
261
|
+
|
|
262
|
+
help_text = {
|
|
263
|
+
"set": "Set a secret value",
|
|
264
|
+
"get": "Get a secret value",
|
|
265
|
+
"list": "List all secrets (use 'list all' for all namespaces)",
|
|
266
|
+
"delete": "Delete a secret",
|
|
267
|
+
"namespace": "Switch namespace or list namespaces (alias: ns)",
|
|
268
|
+
"export": "Export secrets as environment variables",
|
|
269
|
+
"import": "Import secrets from .env file",
|
|
270
|
+
"push": "Push secrets to git store",
|
|
271
|
+
"pull": "Pull secrets from git store",
|
|
272
|
+
"sync": "Sync secrets with git store",
|
|
273
|
+
"status": "Show store status",
|
|
274
|
+
"help": "Show this help",
|
|
275
|
+
"exit": "Exit the shell (alias: quit)",
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
for cmd, desc in help_text.items():
|
|
279
|
+
console.print(f" [cyan]{cmd:12}[/cyan] {desc}")
|
|
280
|
+
|
|
281
|
+
console.print("\n[bold]Examples:[/bold]")
|
|
282
|
+
console.print(" set api-key sk-1234567890")
|
|
283
|
+
console.print(" get api-key")
|
|
284
|
+
console.print(" namespace production")
|
|
285
|
+
console.print(" export file production.env")
|
|
286
|
+
console.print(" import .env.local")
|
|
287
|
+
|
|
288
|
+
def cmd_exit(self, args: List[str]):
|
|
289
|
+
"""Exit the REPL."""
|
|
290
|
+
self.running = False
|
|
291
|
+
console.print("\nGoodbye!")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def run_repl():
|
|
295
|
+
"""Entry point for the REPL."""
|
|
296
|
+
repl = SecretsREPL()
|
|
297
|
+
repl.run()
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Git-based secrets store for synchronization across machines.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, Optional
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
from git import GitCommandError, Repo
|
|
12
|
+
|
|
13
|
+
from mcli.lib.logger.logger import get_logger
|
|
14
|
+
|
|
15
|
+
logger = get_logger(__name__)
|
|
16
|
+
from mcli.lib.ui.styling import error, info, success, warning
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SecretsStore:
|
|
20
|
+
"""Manages git-based secrets synchronization."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, store_path: Optional[Path] = None):
|
|
23
|
+
"""Initialize the secrets store.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
store_path: Path to git repository for secrets. Defaults to ~/repos/mcli-secrets
|
|
27
|
+
"""
|
|
28
|
+
self.store_path = store_path or Path.home() / "repos" / "mcli-secrets"
|
|
29
|
+
self.config_file = Path.home() / ".mcli" / "secrets-store.conf"
|
|
30
|
+
self.load_config()
|
|
31
|
+
|
|
32
|
+
def load_config(self) -> None:
|
|
33
|
+
"""Load store configuration."""
|
|
34
|
+
self.store_config = {}
|
|
35
|
+
if self.config_file.exists():
|
|
36
|
+
with open(self.config_file) as f:
|
|
37
|
+
for line in f:
|
|
38
|
+
line = line.strip()
|
|
39
|
+
if "=" in line:
|
|
40
|
+
key, value = line.split("=", 1)
|
|
41
|
+
self.store_config[key.strip()] = value.strip()
|
|
42
|
+
|
|
43
|
+
# Override store path if configured
|
|
44
|
+
if "store_path" in self.store_config:
|
|
45
|
+
self.store_path = Path(self.store_config["store_path"])
|
|
46
|
+
|
|
47
|
+
def save_config(self) -> None:
|
|
48
|
+
"""Save store configuration."""
|
|
49
|
+
self.config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
with open(self.config_file, "w") as f:
|
|
51
|
+
for key, value in self.store_config.items():
|
|
52
|
+
f.write(f"{key}={value}\n")
|
|
53
|
+
|
|
54
|
+
def init(self, remote_url: Optional[str] = None) -> None:
|
|
55
|
+
"""Initialize the secrets store repository.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
remote_url: Optional git remote URL
|
|
59
|
+
"""
|
|
60
|
+
if self.store_path.exists() and (self.store_path / ".git").exists():
|
|
61
|
+
error("Store already initialized")
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
self.store_path.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
repo = Repo.init(self.store_path)
|
|
68
|
+
|
|
69
|
+
# Create README
|
|
70
|
+
readme_path = self.store_path / "README.md"
|
|
71
|
+
readme_path.write_text(
|
|
72
|
+
"# MCLI Secrets Store\n\n"
|
|
73
|
+
"This repository stores encrypted secrets for MCLI.\n\n"
|
|
74
|
+
"**WARNING**: This repository contains encrypted sensitive data.\n"
|
|
75
|
+
"Ensure it is kept private and access is restricted.\n"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Create .gitignore
|
|
79
|
+
gitignore_path = self.store_path / ".gitignore"
|
|
80
|
+
gitignore_path.write_text("*.key\n*.tmp\n.DS_Store\n")
|
|
81
|
+
|
|
82
|
+
repo.index.add([readme_path.name, gitignore_path.name])
|
|
83
|
+
repo.index.commit("Initial commit")
|
|
84
|
+
|
|
85
|
+
if remote_url:
|
|
86
|
+
repo.create_remote("origin", remote_url)
|
|
87
|
+
self.store_config["remote_url"] = remote_url
|
|
88
|
+
self.save_config()
|
|
89
|
+
info(f"Remote added: {remote_url}")
|
|
90
|
+
|
|
91
|
+
success(f"Secrets store initialized at {self.store_path}")
|
|
92
|
+
|
|
93
|
+
except GitCommandError as e:
|
|
94
|
+
error(f"Failed to initialize store: {e}")
|
|
95
|
+
|
|
96
|
+
def push(self, secrets_dir: Path, message: Optional[str] = None) -> None:
|
|
97
|
+
"""Push secrets to the store.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
secrets_dir: Directory containing encrypted secrets
|
|
101
|
+
message: Commit message
|
|
102
|
+
"""
|
|
103
|
+
if not self._check_initialized():
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
repo = Repo(self.store_path)
|
|
108
|
+
|
|
109
|
+
# Copy secrets to store
|
|
110
|
+
store_secrets_dir = self.store_path / "secrets"
|
|
111
|
+
|
|
112
|
+
# Remove existing secrets
|
|
113
|
+
if store_secrets_dir.exists():
|
|
114
|
+
shutil.rmtree(store_secrets_dir)
|
|
115
|
+
|
|
116
|
+
# Copy new secrets
|
|
117
|
+
shutil.copytree(secrets_dir, store_secrets_dir)
|
|
118
|
+
|
|
119
|
+
# Add to git
|
|
120
|
+
repo.index.add(["secrets"])
|
|
121
|
+
|
|
122
|
+
# Check if there are changes
|
|
123
|
+
if repo.is_dirty():
|
|
124
|
+
message = message or f"Update secrets from {os.uname().nodename}"
|
|
125
|
+
repo.index.commit(message)
|
|
126
|
+
|
|
127
|
+
# Push if remote exists
|
|
128
|
+
if "origin" in repo.remotes:
|
|
129
|
+
info("Pushing to remote...")
|
|
130
|
+
repo.remotes.origin.push()
|
|
131
|
+
success("Secrets pushed to remote")
|
|
132
|
+
else:
|
|
133
|
+
success("Secrets committed locally")
|
|
134
|
+
else:
|
|
135
|
+
info("No changes to push")
|
|
136
|
+
|
|
137
|
+
except GitCommandError as e:
|
|
138
|
+
error(f"Failed to push secrets: {e}")
|
|
139
|
+
|
|
140
|
+
def pull(self, secrets_dir: Path) -> None:
|
|
141
|
+
"""Pull secrets from the store.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
secrets_dir: Directory to store pulled secrets
|
|
145
|
+
"""
|
|
146
|
+
if not self._check_initialized():
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
repo = Repo(self.store_path)
|
|
151
|
+
|
|
152
|
+
# Pull from remote if exists
|
|
153
|
+
if "origin" in repo.remotes:
|
|
154
|
+
info("Pulling from remote...")
|
|
155
|
+
repo.remotes.origin.pull()
|
|
156
|
+
|
|
157
|
+
# Copy secrets from store
|
|
158
|
+
store_secrets_dir = self.store_path / "secrets"
|
|
159
|
+
|
|
160
|
+
if not store_secrets_dir.exists():
|
|
161
|
+
warning("No secrets found in store")
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
# Backup existing secrets
|
|
165
|
+
if secrets_dir.exists():
|
|
166
|
+
backup_dir = secrets_dir.parent / f"{secrets_dir.name}.backup"
|
|
167
|
+
if backup_dir.exists():
|
|
168
|
+
shutil.rmtree(backup_dir)
|
|
169
|
+
shutil.move(str(secrets_dir), str(backup_dir))
|
|
170
|
+
info(f"Existing secrets backed up to {backup_dir}")
|
|
171
|
+
|
|
172
|
+
# Copy secrets from store
|
|
173
|
+
shutil.copytree(store_secrets_dir, secrets_dir)
|
|
174
|
+
|
|
175
|
+
success(f"Secrets pulled to {secrets_dir}")
|
|
176
|
+
|
|
177
|
+
except GitCommandError as e:
|
|
178
|
+
error(f"Failed to pull secrets: {e}")
|
|
179
|
+
|
|
180
|
+
def sync(self, secrets_dir: Path, message: Optional[str] = None) -> None:
|
|
181
|
+
"""Synchronize secrets (pull then push).
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
secrets_dir: Directory containing secrets
|
|
185
|
+
message: Commit message
|
|
186
|
+
"""
|
|
187
|
+
if not self._check_initialized():
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
info("Synchronizing secrets...")
|
|
191
|
+
|
|
192
|
+
# First pull
|
|
193
|
+
self.pull(secrets_dir)
|
|
194
|
+
|
|
195
|
+
# Then push
|
|
196
|
+
self.push(secrets_dir, message)
|
|
197
|
+
|
|
198
|
+
def status(self) -> Dict[str, Any]:
|
|
199
|
+
"""Get status of the secrets store.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
Status information
|
|
203
|
+
"""
|
|
204
|
+
status = {
|
|
205
|
+
"initialized": False,
|
|
206
|
+
"store_path": str(self.store_path),
|
|
207
|
+
"has_remote": False,
|
|
208
|
+
"remote_url": None,
|
|
209
|
+
"clean": True,
|
|
210
|
+
"branch": None,
|
|
211
|
+
"commit": None,
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if not self._check_initialized(silent=True):
|
|
215
|
+
return status
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
repo = Repo(self.store_path)
|
|
219
|
+
status["initialized"] = True
|
|
220
|
+
status["clean"] = not repo.is_dirty()
|
|
221
|
+
status["branch"] = repo.active_branch.name
|
|
222
|
+
status["commit"] = str(repo.head.commit)[:8]
|
|
223
|
+
|
|
224
|
+
if "origin" in repo.remotes:
|
|
225
|
+
status["has_remote"] = True
|
|
226
|
+
status["remote_url"] = repo.remotes.origin.url
|
|
227
|
+
|
|
228
|
+
except Exception:
|
|
229
|
+
pass
|
|
230
|
+
|
|
231
|
+
return status
|
|
232
|
+
|
|
233
|
+
def _check_initialized(self, silent: bool = False) -> bool:
|
|
234
|
+
"""Check if store is initialized.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
silent: Don't print error message
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
True if initialized
|
|
241
|
+
"""
|
|
242
|
+
if not self.store_path.exists() or not (self.store_path / ".git").exists():
|
|
243
|
+
if not silent:
|
|
244
|
+
error("Store not initialized. Run 'mcli secrets store init' first.")
|
|
245
|
+
return False
|
|
246
|
+
return True
|
mcli/self/self_cmd.py
CHANGED
|
@@ -1029,6 +1029,14 @@ try:
|
|
|
1029
1029
|
except ImportError as e:
|
|
1030
1030
|
logger.debug(f"Could not load redis command: {e}")
|
|
1031
1031
|
|
|
1032
|
+
try:
|
|
1033
|
+
from mcli.self.zsh_cmd import zsh_group
|
|
1034
|
+
|
|
1035
|
+
self_app.add_command(zsh_group, name="zsh")
|
|
1036
|
+
logger.debug("Added zsh command to self group")
|
|
1037
|
+
except ImportError as e:
|
|
1038
|
+
logger.debug(f"Could not load zsh command: {e}")
|
|
1039
|
+
|
|
1032
1040
|
try:
|
|
1033
1041
|
from mcli.self.visual_cmd import visual
|
|
1034
1042
|
|
mcli/self/zsh_cmd.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ZSH-specific commands and utilities for MCLI.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
from mcli.lib.logger.logger import get_logger
|
|
13
|
+
|
|
14
|
+
logger = get_logger(__name__)
|
|
15
|
+
from mcli.lib.ui.styling import error, info, success, warning
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@click.group(name="zsh", help="ZSH shell integration and utilities")
|
|
19
|
+
def zsh_group():
|
|
20
|
+
"""ZSH-specific commands and utilities."""
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@zsh_group.command(name="config", help="Configure ZSH for optimal MCLI experience")
|
|
25
|
+
@click.option("--force", is_flag=True, help="Force reconfiguration even if already set up")
|
|
26
|
+
def zsh_config(force: bool):
|
|
27
|
+
"""Configure ZSH with MCLI-specific settings."""
|
|
28
|
+
zshrc = Path.home() / ".zshrc"
|
|
29
|
+
|
|
30
|
+
if not zshrc.exists():
|
|
31
|
+
if click.confirm("No .zshrc found. Create one?"):
|
|
32
|
+
zshrc.touch()
|
|
33
|
+
else:
|
|
34
|
+
warning("Configuration cancelled")
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
configs_added = [] # noqa: F841
|
|
38
|
+
|
|
39
|
+
# Read existing content
|
|
40
|
+
content = zshrc.read_text()
|
|
41
|
+
|
|
42
|
+
# MCLI configuration block
|
|
43
|
+
mcli_block_start = "# BEGIN MCLI ZSH CONFIG"
|
|
44
|
+
mcli_block_end = "# END MCLI ZSH CONFIG"
|
|
45
|
+
|
|
46
|
+
if mcli_block_start in content and not force:
|
|
47
|
+
info("MCLI ZSH configuration already exists. Use --force to reconfigure.")
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
# Remove old block if forcing
|
|
51
|
+
if mcli_block_start in content and force:
|
|
52
|
+
lines = content.split("\n")
|
|
53
|
+
new_lines = []
|
|
54
|
+
in_block = False
|
|
55
|
+
|
|
56
|
+
for line in lines:
|
|
57
|
+
if line.strip() == mcli_block_start:
|
|
58
|
+
in_block = True
|
|
59
|
+
elif line.strip() == mcli_block_end:
|
|
60
|
+
in_block = False
|
|
61
|
+
continue
|
|
62
|
+
elif not in_block:
|
|
63
|
+
new_lines.append(line)
|
|
64
|
+
|
|
65
|
+
content = "\n".join(new_lines)
|
|
66
|
+
|
|
67
|
+
# Build configuration
|
|
68
|
+
config_lines = [
|
|
69
|
+
"",
|
|
70
|
+
mcli_block_start,
|
|
71
|
+
"# MCLI aliases",
|
|
72
|
+
"alias m='mcli'",
|
|
73
|
+
"alias mc='mcli chat'",
|
|
74
|
+
"alias mw='mcli workflow'",
|
|
75
|
+
"alias ms='mcli self'",
|
|
76
|
+
"alias mls='mcli lib secrets'",
|
|
77
|
+
"alias mlsr='mcli lib secrets repl'",
|
|
78
|
+
"",
|
|
79
|
+
"# MCLI environment",
|
|
80
|
+
'export MCLI_HOME="$HOME/.mcli"',
|
|
81
|
+
'export PATH="$MCLI_HOME/bin:$PATH"',
|
|
82
|
+
"",
|
|
83
|
+
"# MCLI completion",
|
|
84
|
+
'fpath=("$HOME/.config/zsh/completions" $fpath)',
|
|
85
|
+
"autoload -U compinit && compinit",
|
|
86
|
+
"",
|
|
87
|
+
"# MCLI prompt integration (optional)",
|
|
88
|
+
'# PS1="%{$fg[cyan]%}[mcli]%{$reset_color%} $PS1"',
|
|
89
|
+
"",
|
|
90
|
+
mcli_block_end,
|
|
91
|
+
"",
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
# Append configuration
|
|
95
|
+
with zshrc.open("a") as f:
|
|
96
|
+
f.write("\n".join(config_lines))
|
|
97
|
+
|
|
98
|
+
success("ZSH configuration added successfully!")
|
|
99
|
+
|
|
100
|
+
# Install completion if not already installed
|
|
101
|
+
completion_dir = Path.home() / ".config" / "zsh" / "completions"
|
|
102
|
+
completion_file = completion_dir / "_mcli"
|
|
103
|
+
|
|
104
|
+
if not completion_file.exists():
|
|
105
|
+
info("Installing ZSH completion...")
|
|
106
|
+
try:
|
|
107
|
+
subprocess.run(["mcli", "self", "completion", "install", "--shell=zsh"], check=True)
|
|
108
|
+
configs_added.append("completion")
|
|
109
|
+
except subprocess.CalledProcessError:
|
|
110
|
+
warning("Failed to install completion automatically")
|
|
111
|
+
|
|
112
|
+
info("\nConfigured:")
|
|
113
|
+
info(" • Aliases: m, mc, mw, ms, mls, mlsr")
|
|
114
|
+
info(" • Environment variables: MCLI_HOME, PATH")
|
|
115
|
+
info(" • Shell completion support")
|
|
116
|
+
info("\nReload your shell configuration:")
|
|
117
|
+
info(" source ~/.zshrc")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@zsh_group.command(name="aliases", help="Show available ZSH aliases")
|
|
121
|
+
def zsh_aliases():
|
|
122
|
+
"""Display MCLI ZSH aliases."""
|
|
123
|
+
aliases = [
|
|
124
|
+
("m", "mcli", "Main MCLI command"),
|
|
125
|
+
("mc", "mcli chat", "Open chat interface"),
|
|
126
|
+
("mw", "mcli workflow", "Workflow commands"),
|
|
127
|
+
("ms", "mcli self", "Self management commands"),
|
|
128
|
+
("mls", "mcli lib secrets", "Secrets management"),
|
|
129
|
+
("mlsr", "mcli lib secrets repl", "Secrets REPL"),
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
info("MCLI ZSH Aliases:")
|
|
133
|
+
for alias, command, desc in aliases:
|
|
134
|
+
click.echo(f" {alias:<6} → {command:<25} # {desc}")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@zsh_group.command(name="prompt", help="Configure ZSH prompt with MCLI integration")
|
|
138
|
+
@click.option("--style", type=click.Choice(["simple", "powerline", "minimal"]), default="simple")
|
|
139
|
+
def zsh_prompt(style: str):
|
|
140
|
+
"""Add MCLI status to ZSH prompt."""
|
|
141
|
+
zshrc = Path.home() / ".zshrc"
|
|
142
|
+
|
|
143
|
+
if not zshrc.exists():
|
|
144
|
+
error("No .zshrc found")
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
prompt_configs = {
|
|
148
|
+
"simple": 'PS1="%{$fg[cyan]%}[mcli]%{$reset_color%} $PS1"',
|
|
149
|
+
"powerline": 'PS1="%{$fg[cyan]%} mcli %{$reset_color%}$PS1"',
|
|
150
|
+
"minimal": 'PS1="◆ $PS1"',
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
config = prompt_configs[style]
|
|
154
|
+
|
|
155
|
+
# Check if prompt section exists
|
|
156
|
+
content = zshrc.read_text()
|
|
157
|
+
prompt_marker = "# MCLI prompt integration"
|
|
158
|
+
|
|
159
|
+
if prompt_marker in content:
|
|
160
|
+
# Update existing prompt
|
|
161
|
+
lines = content.split("\n")
|
|
162
|
+
for i, line in enumerate(lines):
|
|
163
|
+
if line.strip() == prompt_marker:
|
|
164
|
+
if i + 1 < len(lines) and lines[i + 1].startswith("PS1="):
|
|
165
|
+
lines[i + 1] = config
|
|
166
|
+
content = "\n".join(lines)
|
|
167
|
+
zshrc.write_text(content)
|
|
168
|
+
success(f"Updated prompt to {style} style")
|
|
169
|
+
break
|
|
170
|
+
else:
|
|
171
|
+
warning("MCLI ZSH configuration not found. Run 'mcli self zsh config' first.")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@zsh_group.command(name="functions", help="Install useful ZSH functions")
|
|
175
|
+
def zsh_functions():
|
|
176
|
+
"""Install MCLI-specific ZSH functions."""
|
|
177
|
+
functions_dir = Path.home() / ".config" / "zsh" / "functions"
|
|
178
|
+
functions_dir.mkdir(parents=True, exist_ok=True)
|
|
179
|
+
|
|
180
|
+
# Create mcli-quick function
|
|
181
|
+
quick_func = functions_dir / "mcli-quick"
|
|
182
|
+
quick_func.write_text(
|
|
183
|
+
"""# Quick MCLI command runner
|
|
184
|
+
mcli-quick() {
|
|
185
|
+
local cmd=$1
|
|
186
|
+
shift
|
|
187
|
+
mcli $cmd "$@" | head -20
|
|
188
|
+
}"""
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# Create mcli-fzf function for fuzzy finding
|
|
192
|
+
fzf_func = functions_dir / "mcli-fzf"
|
|
193
|
+
fzf_func.write_text(
|
|
194
|
+
"""# Fuzzy find MCLI commands
|
|
195
|
+
mcli-fzf() {
|
|
196
|
+
local cmd=$(mcli --help | grep -E '^ [a-z]' | awk '{print $1}' | fzf)
|
|
197
|
+
if [[ -n $cmd ]]; then
|
|
198
|
+
print -z "mcli $cmd "
|
|
199
|
+
fi
|
|
200
|
+
}"""
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# Add to zshrc
|
|
204
|
+
zshrc = Path.home() / ".zshrc"
|
|
205
|
+
if zshrc.exists():
|
|
206
|
+
content = zshrc.read_text()
|
|
207
|
+
fpath_line = f'fpath=("{functions_dir}" $fpath)'
|
|
208
|
+
|
|
209
|
+
if str(functions_dir) not in content:
|
|
210
|
+
with zshrc.open("a") as f:
|
|
211
|
+
f.write(f"\n# MCLI ZSH functions\n{fpath_line}\nautoload -U mcli-quick mcli-fzf\n")
|
|
212
|
+
|
|
213
|
+
success("ZSH functions installed:")
|
|
214
|
+
info(" • mcli-quick: Run MCLI commands with truncated output")
|
|
215
|
+
info(" • mcli-fzf: Fuzzy find MCLI commands (requires fzf)")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@zsh_group.command(name="test", help="Test ZSH integration")
|
|
219
|
+
def zsh_test():
|
|
220
|
+
"""Test ZSH integration and configuration."""
|
|
221
|
+
checks = []
|
|
222
|
+
|
|
223
|
+
# Check if running in ZSH
|
|
224
|
+
shell = os.environ.get("SHELL", "")
|
|
225
|
+
if "zsh" in shell:
|
|
226
|
+
checks.append(("ZSH shell detected", True))
|
|
227
|
+
else:
|
|
228
|
+
checks.append(("ZSH shell detected", False))
|
|
229
|
+
|
|
230
|
+
# Check completion
|
|
231
|
+
completion_file = Path.home() / ".config" / "zsh" / "completions" / "_mcli"
|
|
232
|
+
checks.append(("Completion installed", completion_file.exists()))
|
|
233
|
+
|
|
234
|
+
# Check zshrc
|
|
235
|
+
zshrc = Path.home() / ".zshrc"
|
|
236
|
+
if zshrc.exists():
|
|
237
|
+
content = zshrc.read_text()
|
|
238
|
+
checks.append(("MCLI config in .zshrc", "BEGIN MCLI ZSH CONFIG" in content))
|
|
239
|
+
checks.append(("Completion in fpath", ".config/zsh/completions" in content))
|
|
240
|
+
else:
|
|
241
|
+
checks.append((".zshrc exists", False))
|
|
242
|
+
|
|
243
|
+
# Check aliases
|
|
244
|
+
try:
|
|
245
|
+
result = subprocess.run(["zsh", "-c", "alias | grep mcli"], capture_output=True, text=True)
|
|
246
|
+
checks.append(("Aliases configured", result.returncode == 0))
|
|
247
|
+
except:
|
|
248
|
+
checks.append(("Aliases configured", False))
|
|
249
|
+
|
|
250
|
+
# Display results
|
|
251
|
+
info("ZSH Integration Test Results:")
|
|
252
|
+
for check, passed in checks:
|
|
253
|
+
status = "✅" if passed else "❌"
|
|
254
|
+
click.echo(f" {status} {check}")
|
|
255
|
+
|
|
256
|
+
if all(passed for _, passed in checks):
|
|
257
|
+
success("\nAll checks passed! ZSH integration is working correctly.")
|
|
258
|
+
else:
|
|
259
|
+
warning("\nSome checks failed. Run 'mcli self zsh config' to set up integration.")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mcli-framework
|
|
3
|
-
Version: 7.
|
|
3
|
+
Version: 7.9.0
|
|
4
4
|
Summary: Portable workflow framework - transform any script into a versioned, schedulable command. Store in ~/.mcli/commands/, 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>
|
|
@@ -48,6 +48,7 @@ Requires-Dist: humanize<5.0.0,>=4.9.0
|
|
|
48
48
|
Requires-Dist: psutil<6.0.0,>=5.9.0
|
|
49
49
|
Requires-Dist: inquirerpy<0.4.0,>=0.3.4
|
|
50
50
|
Requires-Dist: gitpython<4.0.0,>=3.1.40
|
|
51
|
+
Requires-Dist: prompt-toolkit<4.0.0,>=3.0.0
|
|
51
52
|
Requires-Dist: aiohttp>=3.9.0
|
|
52
53
|
Requires-Dist: httpx>=0.28.1
|
|
53
54
|
Requires-Dist: websockets>=12.0
|
|
@@ -5,7 +5,7 @@ mcli/config.toml,sha256=263yEVvP_W9F2zOLssUBgy7amKaRAFQuBrfxcMhKxaQ,1706
|
|
|
5
5
|
mcli/app/__init__.py,sha256=D4RiKk2gOEXwanbe_jXyNSb5zdgNi47kahtskMnEwjY,489
|
|
6
6
|
mcli/app/commands_cmd.py,sha256=gixyyrMV4wDLeTRRDlUx1NG46ceIswsIV3u-zPvtNjw,58655
|
|
7
7
|
mcli/app/completion_helpers.py,sha256=e62C6w2N-XoD66GYYHgtvKKoD3kYMuIeBBGzVKbuL04,7497
|
|
8
|
-
mcli/app/main.py,sha256=
|
|
8
|
+
mcli/app/main.py,sha256=aFQbKMTqClswmwwxpbS5zxVXOXcZMvF27LO19x1X7Cg,19208
|
|
9
9
|
mcli/app/model_cmd.py,sha256=OkFxJwZFCO-8IH6j1FPq-32qqhitbDvrUGf3IooBL54,2562
|
|
10
10
|
mcli/app/model/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
11
|
mcli/app/model/model.py,sha256=EUGu_td-hRlbf4OElkdk1-0p7WyuG7sZmb-Ux2-J9KY,39061
|
|
@@ -19,7 +19,7 @@ mcli/chat/system_controller.py,sha256=SuGvnIh2QObvM1DMicF3gGyeBkbz_xXS-hOOHjWx5j
|
|
|
19
19
|
mcli/chat/system_integration.py,sha256=xQ11thOUswPg8r1HZkId6U3bTCOtMYngt0-mUYYXpt4,40196
|
|
20
20
|
mcli/lib/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
21
21
|
mcli/lib/custom_commands.py,sha256=PCC3uRLN6bcIKCh7GJ98vuNm5R2o9xRRCvCMQcIhj0o,14489
|
|
22
|
-
mcli/lib/lib.py,sha256
|
|
22
|
+
mcli/lib/lib.py,sha256=-CFUfmcubYBxt3LDBY0uj9DF232pz8MPDu-Qg0Ocy8M,850
|
|
23
23
|
mcli/lib/paths.py,sha256=k6sDwvD8QRzBkBOllvXkokameumpTjpJ7pQrP7z1en0,2455
|
|
24
24
|
mcli/lib/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
25
25
|
mcli/lib/api/api.py,sha256=sPgAIYC8Z7AWV2TCBssNSKotbRggBqNLsbfzbjkhmUY,18558
|
|
@@ -57,6 +57,11 @@ mcli/lib/performance/uvloop_config.py,sha256=wyI5pQnec2RAhgm52HJ1AxYGFa3bjTa-Cjh
|
|
|
57
57
|
mcli/lib/pickles/__init__.py,sha256=b7l9CLv8Aua5sROYAaA4raxpWTKSJxUumTLqmzlkb-I,33
|
|
58
58
|
mcli/lib/pickles/pickles.py,sha256=O9dLJfyxViX-IyionbcjcsxHnq42XiLaAorsUrx9oZU,1448
|
|
59
59
|
mcli/lib/search/cached_vectorizer.py,sha256=ShBSTqoyGNGTNAII34okxl4chzy7hqhO2R-jtTEF1_0,17996
|
|
60
|
+
mcli/lib/secrets/__init__.py,sha256=amoAq00syRdQuII3BnvizGmhXbf6vZNN2qnrHtBROvA,241
|
|
61
|
+
mcli/lib/secrets/commands.py,sha256=-PovQ4X1QV3LxqO3hRBIG8JkqA0SJlA_skcg-xCxg-M,6169
|
|
62
|
+
mcli/lib/secrets/manager.py,sha256=kjHNQ7_TeQlF1ZyfhfKZ00Mu1wn06Vw6K8FQL0nyU1s,6698
|
|
63
|
+
mcli/lib/secrets/repl.py,sha256=rmBFVi8lB4r3pRnicpX318IaTrtshPEaBqC-PZaMs0Y,10351
|
|
64
|
+
mcli/lib/secrets/store.py,sha256=vl-K_OjqlNIvypf4BCwCbtNZPk91QngNYqhwWwKupDw,7838
|
|
60
65
|
mcli/lib/services/data_pipeline.py,sha256=UwDpAByOL_PDMjD76aOTmCyStd_QBmCngZBMXVerR5Y,16475
|
|
61
66
|
mcli/lib/services/lsh_client.py,sha256=sJSXlWBqnhNQy7TtYMmcAwBceUp45rYa_HgdyYF0WtI,16799
|
|
62
67
|
mcli/lib/services/redis_service.py,sha256=5QwSB-FMIS1zdTNp8VSOrZfr_wrUK10Bfe2N1ZTy-90,12730
|
|
@@ -191,10 +196,11 @@ mcli/self/__init__.py,sha256=7hCrgaRb4oAgAf-kcyzqhJ5LFpW19jwF5bxowR4LwjY,41
|
|
|
191
196
|
mcli/self/completion_cmd.py,sha256=FKNVc_4ikWTGbDHybiNZGdxrggvt6A6q1rnzuyFVzVM,7754
|
|
192
197
|
mcli/self/logs_cmd.py,sha256=SCzZ4VZs6p42hksun_w4WN33xIZgmq7RjdWX8P2WcT4,15056
|
|
193
198
|
mcli/self/redis_cmd.py,sha256=Cl0LQ3Mqt27gLeb542_xw6bJBbIE-CBmWyMmaUTSk8c,9426
|
|
194
|
-
mcli/self/self_cmd.py,sha256=
|
|
199
|
+
mcli/self/self_cmd.py,sha256=531_8jfX6neSifSl_u3mCG_EcSkQJ9yXcFxklvMFea0,37760
|
|
195
200
|
mcli/self/store_cmd.py,sha256=O6arjRr4qWQKh1QyVWtzyXq5R7yZEBL87FSI59Db7IY,13320
|
|
196
201
|
mcli/self/test_cmd.py,sha256=WjzgoH1WFa79wc8A7O6UMuJfookLfgciUNcCMbKHAQQ,21
|
|
197
202
|
mcli/self/visual_cmd.py,sha256=jXighahHxeM9HANQ2Brk6nKFgi2ZuQBOBH7PE5xhebk,9428
|
|
203
|
+
mcli/self/zsh_cmd.py,sha256=63jKmfjhJp2zxJL2c37OdtdzDrnOreXXfyARN7TyfzU,8294
|
|
198
204
|
mcli/workflow/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
199
205
|
mcli/workflow/lsh_integration.py,sha256=jop80DUjdOSxmqPb-gX_OBep5f1twViv-pXmkcFqBPY,13314
|
|
200
206
|
mcli/workflow/workflow.py,sha256=P_W5LOB3lowvvlfEp3mGwS3eNq4tpbiUY-poFulAF9E,393
|
|
@@ -268,9 +274,9 @@ mcli/workflow/sync/test_cmd.py,sha256=neVgs9zEnKSxlvzDpFkuCGucqnzjrShm2OvJtHibsl
|
|
|
268
274
|
mcli/workflow/videos/__init__.py,sha256=aV3DEoO7qdKJY4odWKoQbOKDQq4ludTeCLnZcupOFIM,25
|
|
269
275
|
mcli/workflow/wakatime/__init__.py,sha256=wKG8cVIHVtMPhNRFGFtX43bRnocHqOMMkFMkmW-M6pU,2626
|
|
270
276
|
mcli/workflow/wakatime/wakatime.py,sha256=sEjsUKa3-XyE8Ni6sAb_D3GAY5jDcA30KknW9YTbLTA,142
|
|
271
|
-
mcli_framework-7.
|
|
272
|
-
mcli_framework-7.
|
|
273
|
-
mcli_framework-7.
|
|
274
|
-
mcli_framework-7.
|
|
275
|
-
mcli_framework-7.
|
|
276
|
-
mcli_framework-7.
|
|
277
|
+
mcli_framework-7.9.0.dist-info/licenses/LICENSE,sha256=sahwAMfrJv2-V66HNPTp7A9UmMjxtyejwTZZoWQvEcI,1075
|
|
278
|
+
mcli_framework-7.9.0.dist-info/METADATA,sha256=JNzXPY0C0wGjCEu8B8DR9r7EiFYlYQ0_DBNFAHrcgRk,16418
|
|
279
|
+
mcli_framework-7.9.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
280
|
+
mcli_framework-7.9.0.dist-info/entry_points.txt,sha256=dYrZbDIm-KUPsl1wfv600Kx_8sMy89phMkCihbDRgP8,261
|
|
281
|
+
mcli_framework-7.9.0.dist-info/top_level.txt,sha256=_bnO8J2EUkliWivey_1le0UrnocFKmyVMQjbQ8iVXjc,5
|
|
282
|
+
mcli_framework-7.9.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|