slots-cli 0.1.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.
- slots/__init__.py +3 -0
- slots/__main__.py +66 -0
- slots/load.py +214 -0
- slots/save.py +108 -0
- slots/slots.py +311 -0
- slots/slots_mcp.py +82 -0
- slots/utils.py +178 -0
- slots_cli-0.1.0.dist-info/METADATA +18 -0
- slots_cli-0.1.0.dist-info/RECORD +12 -0
- slots_cli-0.1.0.dist-info/WHEEL +5 -0
- slots_cli-0.1.0.dist-info/entry_points.txt +3 -0
- slots_cli-0.1.0.dist-info/top_level.txt +1 -0
slots/__init__.py
ADDED
slots/__main__.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
from slots.slots import Slots
|
|
3
|
+
|
|
4
|
+
def main():
|
|
5
|
+
slots = Slots()
|
|
6
|
+
|
|
7
|
+
parser = argparse.ArgumentParser(description="Slots - Save slots for coding projects.")
|
|
8
|
+
|
|
9
|
+
subparsers = parser.add_subparsers(title="Commands", dest="command")
|
|
10
|
+
|
|
11
|
+
init_parser = subparsers.add_parser("init", help="Initialize slots")
|
|
12
|
+
init_parser.add_argument("--refresh", action="store_true", help="Update base to current repository state.")
|
|
13
|
+
|
|
14
|
+
save_parser = subparsers.add_parser("save", help="Save current repository.")
|
|
15
|
+
save_parser.add_argument("name")
|
|
16
|
+
|
|
17
|
+
reset_parser = subparsers.add_parser("reset", help="Destroys all slot-related files and folders.")
|
|
18
|
+
|
|
19
|
+
load_parser = subparsers.add_parser("load", help="Load a save slot.")
|
|
20
|
+
load_parser.add_argument("name")
|
|
21
|
+
load_parser.add_argument("--remove-files", action="store_true", help="Remove files that were deleted in the slot.")
|
|
22
|
+
|
|
23
|
+
delete_parser = subparsers.add_parser("delete", help="Delete a save slot.")
|
|
24
|
+
delete_parser.add_argument("name")
|
|
25
|
+
|
|
26
|
+
list_parser = subparsers.add_parser("list", help="List save slots.")
|
|
27
|
+
|
|
28
|
+
revert_parser = subparsers.add_parser("revert", help="Revert the last load.")
|
|
29
|
+
|
|
30
|
+
status_parser = subparsers.add_parser("status", help="View differences between current repository and base state.")
|
|
31
|
+
status_parser.add_argument("--latest", action="store_true", help="Compare current repository to the most recent save.")
|
|
32
|
+
|
|
33
|
+
args = parser.parse_args()
|
|
34
|
+
|
|
35
|
+
match args.command:
|
|
36
|
+
case "init":
|
|
37
|
+
slots.init(args)
|
|
38
|
+
|
|
39
|
+
case "save":
|
|
40
|
+
slots.save(args)
|
|
41
|
+
|
|
42
|
+
case "reset":
|
|
43
|
+
slots.reset()
|
|
44
|
+
|
|
45
|
+
case "load":
|
|
46
|
+
slots.load(args)
|
|
47
|
+
|
|
48
|
+
case "list":
|
|
49
|
+
slots.list()
|
|
50
|
+
|
|
51
|
+
case "delete":
|
|
52
|
+
slots.delete(args)
|
|
53
|
+
|
|
54
|
+
case "revert":
|
|
55
|
+
slots.revert()
|
|
56
|
+
|
|
57
|
+
case "status":
|
|
58
|
+
slots.status(args)
|
|
59
|
+
|
|
60
|
+
case _:
|
|
61
|
+
print("Command not found. Use --help for list of commands.")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
if __name__ == "__main__":
|
|
65
|
+
main()
|
|
66
|
+
|
slots/load.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import shutil
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
|
|
5
|
+
from colorama import Fore
|
|
6
|
+
|
|
7
|
+
from slots.save import save_current_directory
|
|
8
|
+
from slots.utils import atomic_json, copy_to_destination, remove_file_path, safe_project_path, safe_storage_path
|
|
9
|
+
|
|
10
|
+
def read_slot_layout(path):
|
|
11
|
+
with open(path, "r", encoding="utf-8") as file:
|
|
12
|
+
try:
|
|
13
|
+
layout = json.load(file)
|
|
14
|
+
except json.JSONDecodeError:
|
|
15
|
+
print(Fore.RED + f"Cannot load slot: {path.name} is malformed.")
|
|
16
|
+
return None
|
|
17
|
+
|
|
18
|
+
for key in ("added", "modified", "removed"):
|
|
19
|
+
if not isinstance(layout.get(key), list):
|
|
20
|
+
print(Fore.RED + f"Cannot load slot: layout.json missing '{key}' list.")
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
return layout
|
|
24
|
+
|
|
25
|
+
def create_load_backup(root, base_dir, backups_dir, slot_name, touched_paths):
|
|
26
|
+
backups_dir.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S-%f")
|
|
28
|
+
|
|
29
|
+
backup_name = f"pre-load-{slot_name}-{timestamp}"
|
|
30
|
+
if not save_current_directory(root, backup_name, base_dir, backups_dir):
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
restore_path = backups_dir / backup_name / "restore.json"
|
|
34
|
+
|
|
35
|
+
atomic_json(restore_path, {"touched": sorted(set(touched_paths))})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
return backup_name
|
|
39
|
+
|
|
40
|
+
def load_save(name, saves_dir, base_dir, backups_dir, root, removeFiles=False):
|
|
41
|
+
save_dir = saves_dir / name
|
|
42
|
+
layout_path = save_dir / "layout.json"
|
|
43
|
+
|
|
44
|
+
if not layout_path.exists() or not (base_dir / "layout.json").exists():
|
|
45
|
+
print(Fore.RED + f"Cannot load slot '{name}': layout.json missing.")
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
layout = read_slot_layout(layout_path)
|
|
49
|
+
if layout is None:
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
paths_to_copy = layout["added"] + layout["modified"]
|
|
53
|
+
paths_to_remove = layout["removed"] if removeFiles else []
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
copy_operations = []
|
|
57
|
+
saved_files_dir = save_dir / "files"
|
|
58
|
+
for file_path in paths_to_copy:
|
|
59
|
+
destination = safe_project_path(root, file_path)
|
|
60
|
+
source = safe_storage_path(saved_files_dir, file_path)
|
|
61
|
+
copy_operations.append((source, destination))
|
|
62
|
+
|
|
63
|
+
remove_operations = [safe_project_path(root, file_path) for file_path in paths_to_remove]
|
|
64
|
+
except ValueError as error:
|
|
65
|
+
print(Fore.RED + str(error))
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
for source, _ in copy_operations:
|
|
69
|
+
if not source.exists():
|
|
70
|
+
print(Fore.RED + f"Cannot load slot: saved file missing: {source}")
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
for path in remove_operations:
|
|
74
|
+
if path.exists() and path.is_dir():
|
|
75
|
+
print(Fore.RED + f"Refusing to remove directory during load: {path}")
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
backup_name = create_load_backup(root, base_dir, backups_dir, name, paths_to_copy + paths_to_remove)
|
|
80
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
81
|
+
print(Fore.RED + f"Cannot create pre-load backup: {error}")
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
if backup_name is None:
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
for source, destination in copy_operations:
|
|
89
|
+
copy_to_destination(source, destination)
|
|
90
|
+
|
|
91
|
+
for path in sorted(remove_operations, key=lambda item: len(item.parts), reverse=True):
|
|
92
|
+
remove_file_path(path)
|
|
93
|
+
|
|
94
|
+
except (OSError, shutil.Error) as error:
|
|
95
|
+
print(Fore.RED + f"Load failed: {error}")
|
|
96
|
+
print(Fore.YELLOW + f"Run 'slots revert' to restore backup '{backup_name}'.")
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
return True
|
|
100
|
+
|
|
101
|
+
def find_latest_backup(backups_dir):
|
|
102
|
+
latest_backup = None
|
|
103
|
+
latest_time = None
|
|
104
|
+
|
|
105
|
+
for backup in backups_dir.iterdir():
|
|
106
|
+
info_path = backup / "info.json"
|
|
107
|
+
|
|
108
|
+
if not backup.is_dir() or not info_path.exists():
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
with open(info_path, "r", encoding="utf-8") as file:
|
|
112
|
+
try:
|
|
113
|
+
info = json.load(file)
|
|
114
|
+
except json.JSONDecodeError:
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
saved_time = datetime.fromisoformat(info["time"])
|
|
119
|
+
except (KeyError, TypeError, ValueError):
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
if latest_time is None or saved_time > latest_time:
|
|
123
|
+
latest_backup = backup
|
|
124
|
+
latest_time = saved_time
|
|
125
|
+
|
|
126
|
+
return latest_backup
|
|
127
|
+
|
|
128
|
+
def read_restore_manifest(path):
|
|
129
|
+
if not path.exists():
|
|
130
|
+
print(Fore.RED + "Cannot revert: restore.json missing.")
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
with open(path, "r", encoding="utf-8") as file:
|
|
134
|
+
try:
|
|
135
|
+
manifest = json.load(file)
|
|
136
|
+
except json.JSONDecodeError:
|
|
137
|
+
print(Fore.RED + "Cannot revert: restore.json is malformed.")
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
if not isinstance(manifest.get("touched"), list):
|
|
141
|
+
print(Fore.RED + "Cannot revert: restore.json missing 'touched' list.")
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
return manifest
|
|
145
|
+
|
|
146
|
+
def revert_latest(root, base_dir, backups_dir):
|
|
147
|
+
backup = find_latest_backup(backups_dir)
|
|
148
|
+
|
|
149
|
+
if backup is None:
|
|
150
|
+
print(Fore.RED + "No load backup found.")
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
layout = read_slot_layout(backup / "layout.json")
|
|
154
|
+
if layout is None:
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
manifest = read_restore_manifest(backup / "restore.json")
|
|
158
|
+
if manifest is None:
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
with open(base_dir / "layout.json", "r", encoding="utf-8") as file:
|
|
163
|
+
base_layout = json.load(file)
|
|
164
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
165
|
+
print(Fore.RED + f"Cannot revert: base layout unavailable: {error}")
|
|
166
|
+
return False
|
|
167
|
+
|
|
168
|
+
backup_paths = set(layout["added"] + layout["modified"])
|
|
169
|
+
removed_before_load = set(layout["removed"])
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
copy_operations = []
|
|
173
|
+
remove_operations = []
|
|
174
|
+
backup_files_dir = backup / "files"
|
|
175
|
+
base_files_dir = base_dir / "files"
|
|
176
|
+
|
|
177
|
+
for file_path in manifest["touched"]:
|
|
178
|
+
destination = safe_project_path(root, file_path)
|
|
179
|
+
|
|
180
|
+
if file_path in backup_paths:
|
|
181
|
+
source = safe_storage_path(backup_files_dir, file_path)
|
|
182
|
+
copy_operations.append((source, destination))
|
|
183
|
+
elif file_path in removed_before_load or file_path not in base_layout:
|
|
184
|
+
remove_operations.append(destination)
|
|
185
|
+
else:
|
|
186
|
+
source = safe_storage_path(base_files_dir, file_path)
|
|
187
|
+
copy_operations.append((source, destination))
|
|
188
|
+
|
|
189
|
+
except ValueError as error:
|
|
190
|
+
print(Fore.RED + str(error))
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
for source, _ in copy_operations:
|
|
194
|
+
if not source.exists():
|
|
195
|
+
print(Fore.RED + f"Cannot revert: backup file missing: {source}")
|
|
196
|
+
return False
|
|
197
|
+
|
|
198
|
+
for path in remove_operations:
|
|
199
|
+
if path.exists() and path.is_dir():
|
|
200
|
+
print(Fore.RED + f"Refusing to remove directory during revert: {path}")
|
|
201
|
+
return False
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
for source, destination in copy_operations:
|
|
205
|
+
copy_to_destination(source, destination)
|
|
206
|
+
|
|
207
|
+
for path in sorted(remove_operations, key=lambda item: len(item.parts), reverse=True):
|
|
208
|
+
remove_file_path(path)
|
|
209
|
+
|
|
210
|
+
except OSError as error:
|
|
211
|
+
print(Fore.RED + f"Revert failed: {error}")
|
|
212
|
+
return False
|
|
213
|
+
|
|
214
|
+
return True
|
slots/save.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import shutil
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
|
|
5
|
+
from colorama import Fore
|
|
6
|
+
|
|
7
|
+
from slots.utils import (
|
|
8
|
+
copy_to_destination,
|
|
9
|
+
create_layout,
|
|
10
|
+
safe_project_path,
|
|
11
|
+
safe_storage_path,
|
|
12
|
+
should_ignore,
|
|
13
|
+
slots_dir_name,
|
|
14
|
+
atomic_json
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
def create_base(root):
|
|
18
|
+
base_dir = root / slots_dir_name / "base"
|
|
19
|
+
base_files_dir = base_dir / "files"
|
|
20
|
+
|
|
21
|
+
for item in root.iterdir():
|
|
22
|
+
if should_ignore(item, root) or item.is_symlink():
|
|
23
|
+
continue
|
|
24
|
+
|
|
25
|
+
destination = base_files_dir / item.name
|
|
26
|
+
|
|
27
|
+
if destination.exists():
|
|
28
|
+
continue
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
copy_to_destination(item, destination)
|
|
32
|
+
except PermissionError:
|
|
33
|
+
print(Fore.RED + f"Skipped {item} - Currently in use or no permission.")
|
|
34
|
+
|
|
35
|
+
def refresh_base(root):
|
|
36
|
+
base_dir = root / slots_dir_name / "base"
|
|
37
|
+
base_files_dir = base_dir / "files"
|
|
38
|
+
|
|
39
|
+
if base_dir.exists():
|
|
40
|
+
shutil.rmtree(base_dir)
|
|
41
|
+
|
|
42
|
+
base_files_dir.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
create_base(root)
|
|
44
|
+
|
|
45
|
+
base_layout = create_layout(base_files_dir)
|
|
46
|
+
|
|
47
|
+
atomic_json(base_dir / "layout.json", base_layout)
|
|
48
|
+
|
|
49
|
+
def save_current_directory(root, name, base_dir, saves_dir):
|
|
50
|
+
current_layout = create_layout(root)
|
|
51
|
+
|
|
52
|
+
with open(base_dir / "layout.json", "r", encoding="utf-8") as base_layout_file:
|
|
53
|
+
base_layout = json.load(base_layout_file)
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
for file_path in base_layout:
|
|
57
|
+
safe_project_path(root, file_path)
|
|
58
|
+
except ValueError as error:
|
|
59
|
+
print(Fore.RED + f"Cannot save: {error}")
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
base_paths = set(base_layout)
|
|
63
|
+
current_paths = set(current_layout)
|
|
64
|
+
|
|
65
|
+
added = current_paths - base_paths
|
|
66
|
+
removed = base_paths - current_paths
|
|
67
|
+
shared = base_paths & current_paths
|
|
68
|
+
|
|
69
|
+
modified_files = []
|
|
70
|
+
|
|
71
|
+
for file_path in shared:
|
|
72
|
+
if base_layout[file_path]["hash"] != current_layout[file_path]["hash"]:
|
|
73
|
+
modified_files.append(file_path)
|
|
74
|
+
|
|
75
|
+
current_save_dir = saves_dir / name
|
|
76
|
+
saved_files_dir = current_save_dir / "files"
|
|
77
|
+
current_save_dir.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
saved_files_dir.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
|
|
80
|
+
atomic_json(current_save_dir / "info.json", {
|
|
81
|
+
"name": name,
|
|
82
|
+
"time": datetime.now(timezone.utc).isoformat()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
atomic_json(current_save_dir / "layout.json", {
|
|
86
|
+
"added": sorted(added),
|
|
87
|
+
"removed": sorted(removed),
|
|
88
|
+
"modified": sorted(modified_files)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
# TODO: switch to diff saving instead of saving entire files
|
|
92
|
+
for file_path in list(modified_files) + list(added):
|
|
93
|
+
try:
|
|
94
|
+
source = safe_project_path(root, file_path)
|
|
95
|
+
destination = safe_storage_path(saved_files_dir, file_path)
|
|
96
|
+
except ValueError as error:
|
|
97
|
+
print(Fore.RED + f"Cannot save: {error}")
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
if not source.exists():
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
copy_to_destination(source, destination)
|
|
105
|
+
except PermissionError:
|
|
106
|
+
print(Fore.RED + f"Skipped {source} - Currently in use or no permission.")
|
|
107
|
+
|
|
108
|
+
return True
|
slots/slots.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import json
|
|
3
|
+
import shutil
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
|
|
6
|
+
from colorama import Fore, init
|
|
7
|
+
|
|
8
|
+
from slots.load import load_save, revert_latest
|
|
9
|
+
from slots.save import create_base, refresh_base, save_current_directory
|
|
10
|
+
from slots.utils import (
|
|
11
|
+
create_layout,
|
|
12
|
+
ensure_folders,
|
|
13
|
+
pluralize,
|
|
14
|
+
remove_path,
|
|
15
|
+
slots_dir_name,
|
|
16
|
+
validate_slot_name,
|
|
17
|
+
atomic_json
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
init(autoreset=True)
|
|
21
|
+
|
|
22
|
+
def list_difference(base_dir, root, saves_dir, latest):
|
|
23
|
+
current_layout = create_layout(root)
|
|
24
|
+
|
|
25
|
+
with open(base_dir / "layout.json", "r", encoding="utf-8") as file:
|
|
26
|
+
base_layout = json.load(file)
|
|
27
|
+
|
|
28
|
+
if latest:
|
|
29
|
+
latest_save = None
|
|
30
|
+
latest_time = None
|
|
31
|
+
|
|
32
|
+
for save in saves_dir.iterdir():
|
|
33
|
+
info_path = save / "info.json"
|
|
34
|
+
|
|
35
|
+
if not save.is_dir() or not info_path.exists():
|
|
36
|
+
continue
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
with open(info_path, "r", encoding="utf-8") as file:
|
|
40
|
+
info = json.load(file)
|
|
41
|
+
saved_time = datetime.fromisoformat(info["time"])
|
|
42
|
+
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError):
|
|
43
|
+
continue
|
|
44
|
+
|
|
45
|
+
if latest_time is None or saved_time > latest_time:
|
|
46
|
+
latest_save = save
|
|
47
|
+
latest_time = saved_time
|
|
48
|
+
|
|
49
|
+
if latest_save is None:
|
|
50
|
+
print(Fore.RED + "Cannot show latest diff: no save slots found.")
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
with open(latest_save / "layout.json", "r", encoding="utf-8") as file:
|
|
54
|
+
save_layout = json.load(file)
|
|
55
|
+
|
|
56
|
+
saved_files_layout = create_layout(latest_save / "files")
|
|
57
|
+
source_layout = dict(base_layout)
|
|
58
|
+
|
|
59
|
+
for file_path in save_layout["removed"]:
|
|
60
|
+
source_layout.pop(file_path, None)
|
|
61
|
+
|
|
62
|
+
for file_path in save_layout["added"] + save_layout["modified"]:
|
|
63
|
+
if file_path in saved_files_layout:
|
|
64
|
+
source_layout[file_path] = saved_files_layout[file_path]
|
|
65
|
+
|
|
66
|
+
source_name = f"latest save '{latest_save.name}'"
|
|
67
|
+
else:
|
|
68
|
+
source_name = "base state"
|
|
69
|
+
source_layout = base_layout
|
|
70
|
+
|
|
71
|
+
source_paths = set(source_layout)
|
|
72
|
+
current_paths = set(current_layout)
|
|
73
|
+
added = sorted(current_paths - source_paths)
|
|
74
|
+
removed = sorted(source_paths - current_paths)
|
|
75
|
+
modified = sorted(
|
|
76
|
+
file_path
|
|
77
|
+
for file_path in source_paths & current_paths
|
|
78
|
+
if source_layout[file_path] != current_layout[file_path]
|
|
79
|
+
)
|
|
80
|
+
total = len(added) + len(modified) + len(removed)
|
|
81
|
+
|
|
82
|
+
print(Fore.CYAN + f"Difference from {source_name}")
|
|
83
|
+
print(Fore.WHITE + f"{total} changed: {len(added)} added, {len(modified)} modified, {len(removed)} removed")
|
|
84
|
+
|
|
85
|
+
if total == 0:
|
|
86
|
+
print(Fore.GREEN + "No differences found.")
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
if added:
|
|
90
|
+
print(Fore.GREEN + f"\nAdded ({len(added)})")
|
|
91
|
+
for path in added:
|
|
92
|
+
print(Fore.GREEN + f" + {path}")
|
|
93
|
+
|
|
94
|
+
if modified:
|
|
95
|
+
print(Fore.YELLOW + f"\nModified ({len(modified)})")
|
|
96
|
+
for path in modified:
|
|
97
|
+
print(Fore.YELLOW + f" ~ {path}")
|
|
98
|
+
|
|
99
|
+
if removed:
|
|
100
|
+
print(Fore.RED + f"\nRemoved ({len(removed)})")
|
|
101
|
+
for path in removed:
|
|
102
|
+
print(Fore.RED + f" - {path}")
|
|
103
|
+
|
|
104
|
+
def clear_saves(saves_dir):
|
|
105
|
+
for item in saves_dir.iterdir():
|
|
106
|
+
remove_path(item)
|
|
107
|
+
|
|
108
|
+
def format_time_since(saved_time):
|
|
109
|
+
now = datetime.now(saved_time.tzinfo or timezone.utc)
|
|
110
|
+
seconds_since_save = max(0, round((now - saved_time).total_seconds()))
|
|
111
|
+
|
|
112
|
+
if seconds_since_save < 60:
|
|
113
|
+
return f"{pluralize(seconds_since_save, 'second')} ago"
|
|
114
|
+
|
|
115
|
+
minutes_since_save = round(seconds_since_save / 60)
|
|
116
|
+
|
|
117
|
+
if minutes_since_save < 60:
|
|
118
|
+
return f"{pluralize(minutes_since_save, 'minute')} ago"
|
|
119
|
+
|
|
120
|
+
hours = minutes_since_save // 60
|
|
121
|
+
minutes = minutes_since_save % 60
|
|
122
|
+
|
|
123
|
+
if minutes == 0:
|
|
124
|
+
return f"{pluralize(hours, 'hour')} ago"
|
|
125
|
+
|
|
126
|
+
return f"{pluralize(hours, 'hour')} {pluralize(minutes, 'minute')} ago"
|
|
127
|
+
|
|
128
|
+
def format_saved_time(saved_time):
|
|
129
|
+
return saved_time.astimezone().strftime("%H:%M")
|
|
130
|
+
|
|
131
|
+
def list_saves(saves_dir):
|
|
132
|
+
saves = []
|
|
133
|
+
for save in saves_dir.iterdir():
|
|
134
|
+
info_path = save / "info.json"
|
|
135
|
+
|
|
136
|
+
if not save.is_dir():
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
if not info_path.exists():
|
|
140
|
+
print(Fore.YELLOW + f"Skipping save '{save.name}', missing info.json")
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
with open(info_path, "r", encoding="utf-8") as file:
|
|
144
|
+
try:
|
|
145
|
+
info = json.load(file)
|
|
146
|
+
except json.JSONDecodeError:
|
|
147
|
+
print(Fore.YELLOW + f"Skipping save '{save.name}', malformed info.json")
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
saved_time_value = info.get("time") or info.get("saved_at")
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
saved_time = datetime.fromisoformat(saved_time_value)
|
|
154
|
+
except (TypeError, ValueError):
|
|
155
|
+
print(Fore.YELLOW + f"Skipping save '{save.name}', missing or invalid save time")
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
saves.append({
|
|
159
|
+
"name": info.get("name", save.name),
|
|
160
|
+
"time": saved_time
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
saves.sort(
|
|
164
|
+
key=lambda save: save["time"],
|
|
165
|
+
reverse=True
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
print(Fore.GREEN + "Save slots: ")
|
|
169
|
+
for save in saves:
|
|
170
|
+
time_since_save = format_time_since(save["time"])
|
|
171
|
+
saved_at = format_saved_time(save["time"])
|
|
172
|
+
print(f"{save['name']} - {time_since_save} (Saved at {saved_at})")
|
|
173
|
+
|
|
174
|
+
class Slots:
|
|
175
|
+
def __init__(self):
|
|
176
|
+
self.root = Path.cwd()
|
|
177
|
+
self.slots_dir = self.root / slots_dir_name
|
|
178
|
+
self.base_dir = self.slots_dir / "base"
|
|
179
|
+
self.saves_dir = self.slots_dir / "saves"
|
|
180
|
+
self.backups_dir = self.slots_dir / "backups"
|
|
181
|
+
|
|
182
|
+
def init(self, args):
|
|
183
|
+
self.root = Path.cwd()
|
|
184
|
+
|
|
185
|
+
if args.refresh:
|
|
186
|
+
print(Fore.YELLOW + "Are you sure you want to refresh the base repository state? (This will clear all save slots.)")
|
|
187
|
+
confirmation = input("[y/n]: ")
|
|
188
|
+
|
|
189
|
+
if confirmation.lower().strip() == "y":
|
|
190
|
+
if not Path.exists(self.slots_dir):
|
|
191
|
+
print(Fore.RED + "Cannot refresh base state: Slots not initialized.")
|
|
192
|
+
return
|
|
193
|
+
refresh_base(self.root)
|
|
194
|
+
clear_saves(self.saves_dir)
|
|
195
|
+
print(Fore.GREEN + "Refreshed base repository to reflect current state.")
|
|
196
|
+
else:
|
|
197
|
+
print(Fore.WHITE + "Operation canceled.")
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
if Path.exists(self.root / slots_dir_name):
|
|
201
|
+
print(Fore.RED + "Slots already initialized.")
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
ensure_folders([self.slots_dir, self.base_dir / "files", self.saves_dir, self.backups_dir])
|
|
205
|
+
create_base(self.root)
|
|
206
|
+
base_layout = create_layout(self.base_dir / "files")
|
|
207
|
+
|
|
208
|
+
atomic_json(self.base_dir / "layout.json", base_layout)
|
|
209
|
+
|
|
210
|
+
print(Fore.GREEN + "Initialized Slots.")
|
|
211
|
+
|
|
212
|
+
def save(self, args):
|
|
213
|
+
if not Path.exists(self.root / slots_dir_name):
|
|
214
|
+
print(Fore.RED + "Cannot save: Slots not initialized.")
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
if Path.exists(self.saves_dir / args.name):
|
|
218
|
+
print(Fore.RED + f"Cannot save: Slot with the name '{args.name}' already exists.")
|
|
219
|
+
return
|
|
220
|
+
|
|
221
|
+
error = validate_slot_name(args.name)
|
|
222
|
+
if error:
|
|
223
|
+
print(Fore.RED + f"Invalid slot name: {error}")
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
saved = save_current_directory(self.root, args.name.strip(), self.base_dir, self.saves_dir)
|
|
227
|
+
|
|
228
|
+
if saved:
|
|
229
|
+
print(Fore.GREEN + f"Created save slot '{args.name}'.")
|
|
230
|
+
|
|
231
|
+
def load(self, args):
|
|
232
|
+
if not Path.exists(self.root / slots_dir_name):
|
|
233
|
+
print(Fore.RED + "Cannot load: Slots not initialized.")
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
error = validate_slot_name(args.name)
|
|
237
|
+
if error:
|
|
238
|
+
print(Fore.RED + f"Invalid slot name: {error}")
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
if not Path.exists(self.saves_dir / args.name):
|
|
242
|
+
print(Fore.RED + f"No slot with name '{args.name}' found.")
|
|
243
|
+
return
|
|
244
|
+
|
|
245
|
+
removeFiles = getattr(args, "remove_files", False)
|
|
246
|
+
loaded = load_save(args.name.strip(), self.saves_dir, self.base_dir, self.backups_dir, self.root, removeFiles)
|
|
247
|
+
|
|
248
|
+
if loaded:
|
|
249
|
+
print(Fore.GREEN + f"Loaded save slot '{args.name}'.")
|
|
250
|
+
|
|
251
|
+
def list(self):
|
|
252
|
+
if not Path.exists(self.root / slots_dir_name):
|
|
253
|
+
print(Fore.RED + "Cannot list: Slots not initialized.")
|
|
254
|
+
return
|
|
255
|
+
|
|
256
|
+
list_saves(self.saves_dir)
|
|
257
|
+
|
|
258
|
+
def delete(self, args):
|
|
259
|
+
if not Path.exists(self.root / slots_dir_name):
|
|
260
|
+
print(Fore.RED + "Cannot delete: Slots not initialized.")
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
error = validate_slot_name(args.name)
|
|
264
|
+
if error:
|
|
265
|
+
print(Fore.RED + f"Invalid slot name: {error}")
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
if not Path.exists(self.saves_dir / args.name):
|
|
269
|
+
print(Fore.RED + f"No slot with name '{args.name}' found.")
|
|
270
|
+
return
|
|
271
|
+
|
|
272
|
+
slot_path = self.saves_dir / args.name.strip()
|
|
273
|
+
|
|
274
|
+
remove_path(slot_path)
|
|
275
|
+
print(Fore.GREEN + f"Deleted slot '{args.name.strip()}'")
|
|
276
|
+
|
|
277
|
+
def reset(self):
|
|
278
|
+
print(Fore.YELLOW + "Are you sure you want to reset all slots configuration?")
|
|
279
|
+
confirmation = input("[y/n]: ")
|
|
280
|
+
|
|
281
|
+
if confirmation.lower().strip() != "y":
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
print(Fore.RED + "This action is irreversible. Please make sure this is what you want to do.")
|
|
285
|
+
confirmation = input("[y/n]: ")
|
|
286
|
+
|
|
287
|
+
if confirmation.lower().strip() != "y":
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
shutil.rmtree(self.slots_dir)
|
|
291
|
+
print(Fore.GREEN + "Cleared all slots data. Use 'slots init' to reinitialize slots.")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def revert(self):
|
|
295
|
+
if not Path.exists(self.root / slots_dir_name):
|
|
296
|
+
print(Fore.RED + "Cannot revert: Slots not initialized.")
|
|
297
|
+
return
|
|
298
|
+
|
|
299
|
+
reverted = revert_latest(self.root, self.base_dir, self.backups_dir)
|
|
300
|
+
|
|
301
|
+
if reverted:
|
|
302
|
+
print(Fore.GREEN + "Reverted last load.")
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def status(self, args=None):
|
|
306
|
+
if not Path.exists(self.root / slots_dir_name):
|
|
307
|
+
print(Fore.RED + "Cannot show status: Slots not initialized.")
|
|
308
|
+
return
|
|
309
|
+
|
|
310
|
+
latest = getattr(args, "latest", False)
|
|
311
|
+
list_difference(self.base_dir, self.root, self.saves_dir, latest)
|
slots/slots_mcp.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import os
|
|
3
|
+
from contextlib import redirect_stdout
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from types import SimpleNamespace
|
|
6
|
+
|
|
7
|
+
from mcp.server.fastmcp import FastMCP
|
|
8
|
+
|
|
9
|
+
from slots.slots import Slots
|
|
10
|
+
|
|
11
|
+
mcp = FastMCP("Slots")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def slots_cmd(project, command):
|
|
15
|
+
original_cwd = Path.cwd()
|
|
16
|
+
output = io.StringIO()
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
os.chdir(Path(project).expanduser().resolve())
|
|
20
|
+
slots = Slots()
|
|
21
|
+
|
|
22
|
+
with redirect_stdout(output):
|
|
23
|
+
command(slots)
|
|
24
|
+
return output.getvalue().strip() or "Done."
|
|
25
|
+
finally:
|
|
26
|
+
os.chdir(original_cwd)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@mcp.tool()
|
|
30
|
+
def slots_init(project_path: str = ".") -> str:
|
|
31
|
+
"""Initialize Slots in a project."""
|
|
32
|
+
return slots_cmd(
|
|
33
|
+
project_path,
|
|
34
|
+
lambda slots: slots.init(SimpleNamespace(refresh=False)),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@mcp.tool()
|
|
39
|
+
def slots_status(project_path: str = ".", latest: bool = False) -> str:
|
|
40
|
+
"""Show differences in a Slots project."""
|
|
41
|
+
return slots_cmd(
|
|
42
|
+
project_path,
|
|
43
|
+
lambda slots: slots.status(SimpleNamespace(latest=latest)),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@mcp.tool()
|
|
48
|
+
def slots_list(project_path: str = ".") -> str:
|
|
49
|
+
"""List save slots in a project."""
|
|
50
|
+
return slots_cmd(project_path, lambda slots: slots.list())
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@mcp.tool()
|
|
54
|
+
def slots_save(project_path: str, name: str) -> str:
|
|
55
|
+
"""Create a named save slot for a project."""
|
|
56
|
+
return slots_cmd(
|
|
57
|
+
project_path,
|
|
58
|
+
lambda slots: slots.save(SimpleNamespace(name=name)),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@mcp.tool()
|
|
63
|
+
def slots_load(project_path: str, name: str, remove_files: bool = False) -> str:
|
|
64
|
+
"""Load a named save slot into a project."""
|
|
65
|
+
return slots_cmd(
|
|
66
|
+
project_path,
|
|
67
|
+
lambda slots: slots.load(SimpleNamespace(name=name, remove_files=remove_files)),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@mcp.tool()
|
|
72
|
+
def slots_revert(project_path: str = ".") -> str:
|
|
73
|
+
"""Revert the most recent slot load."""
|
|
74
|
+
return slots_cmd(project_path, lambda slots: slots.revert())
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def main():
|
|
78
|
+
mcp.run()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if __name__ == "__main__":
|
|
82
|
+
main()
|
slots/utils.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import hashlib
|
|
3
|
+
import re
|
|
4
|
+
import shutil
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
import json
|
|
8
|
+
|
|
9
|
+
slots_dir_name = ".slots"
|
|
10
|
+
IGNORE_LIST = [".slots", ".git", "node_modules", ".venv", "venv", "__pycache__"]
|
|
11
|
+
|
|
12
|
+
WINDOWS_RESERVED_NAMES = {
|
|
13
|
+
"CON", "PRN", "AUX", "NUL",
|
|
14
|
+
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
|
15
|
+
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
def validate_slot_name(name):
|
|
19
|
+
if not name or not name.strip():
|
|
20
|
+
return "Slot name cannot be empty."
|
|
21
|
+
|
|
22
|
+
name = name.strip()
|
|
23
|
+
|
|
24
|
+
if name in {".", ".."}:
|
|
25
|
+
return "Slot name cannot be '.' or '..'."
|
|
26
|
+
|
|
27
|
+
if Path(name).is_absolute() or "/" in name or "\\" in name:
|
|
28
|
+
return "Slot name cannot be a path."
|
|
29
|
+
|
|
30
|
+
if name.upper() in WINDOWS_RESERVED_NAMES:
|
|
31
|
+
return f"'{name}' is a reserved system name."
|
|
32
|
+
|
|
33
|
+
if len(name) > 64:
|
|
34
|
+
return "Slot name must be 64 characters or fewer."
|
|
35
|
+
|
|
36
|
+
if not re.fullmatch(r"[A-Za-z0-9 _-]+", name):
|
|
37
|
+
return "Slot name can only contain letters, numbers, spaces, underscores, and hyphens."
|
|
38
|
+
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
def ensure_folders(dirs_paths):
|
|
42
|
+
for dir_path in dirs_paths:
|
|
43
|
+
dir_path.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
|
|
45
|
+
def should_ignore(item, root):
|
|
46
|
+
relative = item.relative_to(root)
|
|
47
|
+
return any(part in IGNORE_LIST for part in relative.parts)
|
|
48
|
+
|
|
49
|
+
def hash_file(path):
|
|
50
|
+
sha = hashlib.sha256()
|
|
51
|
+
|
|
52
|
+
with open(path, "rb") as f:
|
|
53
|
+
while chunk := f.read(8192):
|
|
54
|
+
sha.update(chunk)
|
|
55
|
+
|
|
56
|
+
return sha.hexdigest()
|
|
57
|
+
|
|
58
|
+
def create_layout(path):
|
|
59
|
+
layout = {}
|
|
60
|
+
|
|
61
|
+
for item in path.rglob("*"):
|
|
62
|
+
if should_ignore(item, path) or item.is_symlink():
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
relative = item.relative_to(path)
|
|
66
|
+
|
|
67
|
+
layout[str(relative)] = {
|
|
68
|
+
"type": "dir" if item.is_dir() else "file",
|
|
69
|
+
"hash": hash_file(item) if item.is_file() else None
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return layout
|
|
73
|
+
|
|
74
|
+
def remove_path(path):
|
|
75
|
+
if not path.exists():
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
if path.is_dir():
|
|
79
|
+
shutil.rmtree(path)
|
|
80
|
+
else:
|
|
81
|
+
path.unlink()
|
|
82
|
+
|
|
83
|
+
def remove_file_path(path):
|
|
84
|
+
if not path.exists():
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
if path.is_dir():
|
|
88
|
+
raise IsADirectoryError(f"Refusing to remove directory: {path}")
|
|
89
|
+
|
|
90
|
+
path.unlink()
|
|
91
|
+
|
|
92
|
+
def safe_project_path(root, relative_path):
|
|
93
|
+
if not isinstance(relative_path, str):
|
|
94
|
+
raise ValueError(f"Unsafe path in slot metadata: {relative_path}")
|
|
95
|
+
|
|
96
|
+
path = Path(relative_path)
|
|
97
|
+
|
|
98
|
+
if not path.parts or path.is_absolute() or ".." in path.parts:
|
|
99
|
+
raise ValueError(f"Unsafe path in slot metadata: {relative_path}")
|
|
100
|
+
|
|
101
|
+
if path.parts and path.parts[0] in {".git", slots_dir_name}:
|
|
102
|
+
raise ValueError(f"Refusing to touch protected path: {relative_path}")
|
|
103
|
+
|
|
104
|
+
root = root.resolve()
|
|
105
|
+
destination = (root / path).resolve()
|
|
106
|
+
|
|
107
|
+
if destination != root and root not in destination.parents:
|
|
108
|
+
raise ValueError(f"Path escapes project root: {relative_path}")
|
|
109
|
+
|
|
110
|
+
return destination
|
|
111
|
+
|
|
112
|
+
def safe_storage_path(root, relative_path):
|
|
113
|
+
if not isinstance(relative_path, str):
|
|
114
|
+
raise ValueError(f"Unsafe path in slot metadata: {relative_path}")
|
|
115
|
+
|
|
116
|
+
path = Path(relative_path)
|
|
117
|
+
|
|
118
|
+
if not path.parts or path.is_absolute() or ".." in path.parts:
|
|
119
|
+
raise ValueError(f"Unsafe path in slot metadata: {relative_path}")
|
|
120
|
+
|
|
121
|
+
root = root.resolve()
|
|
122
|
+
destination = (root / path).resolve()
|
|
123
|
+
|
|
124
|
+
if destination != root and root not in destination.parents:
|
|
125
|
+
raise ValueError(f"Path escapes slot storage: {relative_path}")
|
|
126
|
+
|
|
127
|
+
return destination
|
|
128
|
+
|
|
129
|
+
def copy_to_destination(source, destination):
|
|
130
|
+
if source.is_symlink():
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
|
|
135
|
+
if destination.is_symlink():
|
|
136
|
+
destination.unlink()
|
|
137
|
+
|
|
138
|
+
if source.is_dir():
|
|
139
|
+
if destination.exists() and not destination.is_dir():
|
|
140
|
+
destination.unlink()
|
|
141
|
+
|
|
142
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
|
|
144
|
+
for item in source.iterdir():
|
|
145
|
+
copy_to_destination(item, destination / item.name)
|
|
146
|
+
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
if destination.exists() and destination.is_dir():
|
|
150
|
+
shutil.rmtree(destination)
|
|
151
|
+
|
|
152
|
+
shutil.copy2(source, destination)
|
|
153
|
+
|
|
154
|
+
def pluralize(value, unit):
|
|
155
|
+
if value == 1:
|
|
156
|
+
return f"{value} {unit}"
|
|
157
|
+
|
|
158
|
+
return f"{value} {unit}s"
|
|
159
|
+
|
|
160
|
+
def atomic_json(path, data):
|
|
161
|
+
path = Path(path)
|
|
162
|
+
temp_path = None
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as file:
|
|
166
|
+
temp_path = Path(file.name)
|
|
167
|
+
|
|
168
|
+
json.dump(data, file, indent=4, sort_keys=True)
|
|
169
|
+
file.write("\n")
|
|
170
|
+
file.flush()
|
|
171
|
+
os.fsync(file.fileno())
|
|
172
|
+
|
|
173
|
+
os.replace(temp_path, path)
|
|
174
|
+
|
|
175
|
+
except OSError:
|
|
176
|
+
if temp_path is not None and temp_path.exists():
|
|
177
|
+
temp_path.unlink()
|
|
178
|
+
raise
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: slots-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Save slots for coding projects.
|
|
5
|
+
Home-page: https://slots.hohohocch.com
|
|
6
|
+
Author: hohohocch
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: colorama
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest; extra == "dev"
|
|
11
|
+
Provides-Extra: mcp
|
|
12
|
+
Requires-Dist: mcp[cli]; extra == "mcp"
|
|
13
|
+
Dynamic: author
|
|
14
|
+
Dynamic: home-page
|
|
15
|
+
Dynamic: provides-extra
|
|
16
|
+
Dynamic: requires-dist
|
|
17
|
+
Dynamic: requires-python
|
|
18
|
+
Dynamic: summary
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
slots/__init__.py,sha256=-2AJDkE_B3X6ziPuj_rtw8E1ecPI4idjglRokppmG3A,51
|
|
2
|
+
slots/__main__.py,sha256=aKWuyB9qq5GCT5YgBIUJ4zMXE9eWqO1HTfVZPkIzJss,2100
|
|
3
|
+
slots/load.py,sha256=XVdNe7XWiB1qjkf3hKq7Fse5d5yUNs37RhSAMushw84,7085
|
|
4
|
+
slots/save.py,sha256=YcbHTjfWbHxm2i4dIZCgb4UoCGn3QczJ5G6NUyM--r0,3116
|
|
5
|
+
slots/slots.py,sha256=QCx2Axsl_rUpKwEXQGCu4Syj2cRiiw_F28ILIB9r3QQ,10261
|
|
6
|
+
slots/slots_mcp.py,sha256=wr1M7Zi6qw8M98gQVpXBxjbfPO8uLFVzrpYtrKPGcqU,1944
|
|
7
|
+
slots/utils.py,sha256=0hpkbD_vDxtLQqW65AaTF3LFHwEQEnWEp67NOSdJg7E,4817
|
|
8
|
+
slots_cli-0.1.0.dist-info/METADATA,sha256=2B0gthwdY0GQvPEfUyAVtBh-mqboCL3WeYiqxivIR7M,459
|
|
9
|
+
slots_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
10
|
+
slots_cli-0.1.0.dist-info/entry_points.txt,sha256=Y2svdyeHZUFzHRnRrT6LdNOgYGmWss2n2fEnkCSzp9o,79
|
|
11
|
+
slots_cli-0.1.0.dist-info/top_level.txt,sha256=i2OhezRJlgfmhWJr_M5_16fPi80KyTU9JAbkmrOphCQ,6
|
|
12
|
+
slots_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
slots
|