slots-cli 0.1.0__tar.gz

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.
@@ -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,186 @@
1
+ ![Slots repo banner](repo_banner.png)
2
+
3
+ # Slots
4
+
5
+ Save slots for your local directory - Checkpoint your progress along the way with no commitment.
6
+
7
+ Slots is a small CLI for creating save slots inside a coding project. It is useful when you want to attempt a refactor, compare approaches, or let an AI coding agent make changes while keeping an easy restore point.
8
+
9
+ ## Installation
10
+
11
+ ```powershell
12
+ pip install slots-cli
13
+ ```
14
+
15
+ For MCP support:
16
+
17
+ ```powershell
18
+ pip install "slots-cli[mcp]"
19
+ ```
20
+
21
+ ## Quickstart
22
+
23
+ Create a save slot before making risky changes:
24
+
25
+ ```powershell
26
+ slots init
27
+ slots save before-refactor
28
+ ```
29
+
30
+ Make your edits, then check what changed:
31
+
32
+ ```powershell
33
+ slots status
34
+ ```
35
+
36
+ ```diff
37
+ Difference from base state
38
+ 3 changed: 1 added, 1 modified, 1 removed
39
+
40
+ Added (1)
41
+ + new_feature.py
42
+
43
+ Modified (1)
44
+ ! app.py
45
+
46
+ Removed (1)
47
+ - old_utils.py
48
+ ```
49
+
50
+ Restore the save slot if you want to go back:
51
+
52
+ ```powershell
53
+ slots load before-refactor
54
+ ```
55
+
56
+ Undo the most recent load if you change your mind:
57
+
58
+ ```powershell
59
+ slots revert
60
+ ```
61
+
62
+ ## Commands
63
+
64
+ ### Initialize
65
+
66
+ ```powershell
67
+ slots init
68
+ ```
69
+
70
+ Creates `.slots/` and records the base state. All saves are tied to the same base state.
71
+
72
+ Refresh the base state:
73
+
74
+ ```powershell
75
+ slots init --refresh
76
+ ```
77
+
78
+ This clears existing save slots and aligns the base state with the current respository.
79
+
80
+ ### Save
81
+
82
+ ```powershell
83
+ slots save before-refactor
84
+ ```
85
+
86
+ Creates a named save slot from the current project state.
87
+
88
+ ### Status
89
+
90
+ ```powershell
91
+ slots status
92
+ ```
93
+
94
+ Shows differences between the current project and the base state.
95
+
96
+ ```powershell
97
+ slots status --latest
98
+ ```
99
+
100
+ Shows differences between the current project and the most recent save slot.
101
+
102
+ ### List
103
+
104
+ ```powershell
105
+ slots list
106
+ ```
107
+
108
+ Lists save slots.
109
+
110
+ ### Load
111
+
112
+ ```powershell
113
+ slots load before-refactor
114
+ ```
115
+
116
+ Loads added and modified files from a save slot.
117
+
118
+ ```powershell
119
+ slots load before-refactor --remove-files
120
+ ```
121
+
122
+ Also removes files that were deleted in the save slot.
123
+
124
+ ### Revert
125
+
126
+ ```powershell
127
+ slots revert
128
+ ```
129
+
130
+ Reverts the most recent load using the automatic pre-load backup.
131
+
132
+ ### Delete
133
+
134
+ ```powershell
135
+ slots delete before-refactor
136
+ ```
137
+
138
+ Deletes a save slot.
139
+
140
+ ### Reset
141
+
142
+ ```powershell
143
+ slots reset
144
+ ```
145
+
146
+ Deletes all Slots data from the project.
147
+
148
+ ## MCP Support
149
+
150
+ Slots includes a MCP server so AI coding tools can create and restore save slots.
151
+
152
+ Install with MCP support:
153
+
154
+ ```powershell
155
+ pip install "slots-cli[mcp]"
156
+ ```
157
+
158
+ Then run the MCP server:
159
+
160
+ ```powershell
161
+ slots-mcp
162
+ ```
163
+
164
+ ### Common Installation commands:
165
+
166
+ **Codex**
167
+
168
+ ```powershell
169
+ codex mcp add slots -- slots-mcp
170
+ ```
171
+
172
+ **Claude Code**
173
+
174
+ ```powershell
175
+ claude mcp add slots -- slots-mcp
176
+ ```
177
+
178
+ ## Notes
179
+
180
+ Slots is not a replacement for Git - it is designed to work alongside it as a lightweight local checkpoint tool.
181
+
182
+ Before loading a slot, Slots creates a backup in `.slots/backups/`. You can restore the latest load with:
183
+
184
+ ```powershell
185
+ slots revert
186
+ ```
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,22 @@
1
+ from setuptools import find_packages, setup
2
+
3
+ setup(
4
+ name="slots-cli",
5
+ version="0.1.0",
6
+ packages=find_packages(),
7
+ description="Save slots for coding projects.",
8
+ author="hohohocch",
9
+ url="https://slots.hohohocch.com",
10
+ python_requires=">=3.10",
11
+ install_requires=["colorama"],
12
+ entry_points={
13
+ "console_scripts": [
14
+ "slots = slots.__main__:main",
15
+ "slots-mcp = slots.slots_mcp:main",
16
+ ],
17
+ },
18
+ extras_require={
19
+ "dev": ["pytest"],
20
+ "mcp": ["mcp[cli]"],
21
+ },
22
+ )
@@ -0,0 +1,3 @@
1
+ from slots.slots import Slots
2
+
3
+ __all__ = ["Slots"]
@@ -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
+
@@ -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
@@ -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