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 ADDED
@@ -0,0 +1,3 @@
1
+ from slots.slots import Slots
2
+
3
+ __all__ = ["Slots"]
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ slots = slots.__main__:main
3
+ slots-mcp = slots.slots_mcp:main
@@ -0,0 +1 @@
1
+ slots