tenseleyflow/shtick / 23d0de2

Browse files

extend group functionality

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
23d0de20878dbd5724098e7f0454dce33ec7baea
Parents
cceeb7c
Tree
5bf7284

5 changed files

StatusFile+-
M src/shtick/cli.py 67 1
M src/shtick/commands.py 168 55
M src/shtick/config.py 69 31
M src/shtick/security.py 31 7
M src/shtick/shtick.py 88 0
src/shtick/cli.pymodified
@@ -116,6 +116,54 @@ def main():
116116
         help="Show one shell per line instead of columns",
117117
     )
118118
 
119
+    # Add group management commands
120
+    group_parser = subparsers.add_parser("group", help="Group management commands")
121
+    group_subparsers = group_parser.add_subparsers(
122
+        dest="group_command", help="Group commands"
123
+    )
124
+
125
+    # Create group
126
+    create_parser = group_subparsers.add_parser("create", help="Create a new group")
127
+    create_parser.add_argument("name", help="Group name")
128
+    create_parser.add_argument("-d", "--description", help="Group description")
129
+
130
+    # Remove group
131
+    remove_parser = group_subparsers.add_parser("remove", help="Remove a group")
132
+    remove_parser.add_argument("name", help="Group name")
133
+    remove_parser.add_argument(
134
+        "-f", "--force", action="store_true", help="Force removal without confirmation"
135
+    )
136
+
137
+    # Rename group
138
+    rename_parser = group_subparsers.add_parser("rename", help="Rename a group")
139
+    rename_parser.add_argument("old_name", help="Current group name")
140
+    rename_parser.add_argument("new_name", help="New group name")
141
+
142
+    # Backup commands
143
+    backup_parser = subparsers.add_parser("backup", help="Backup management commands")
144
+    backup_subparsers = backup_parser.add_subparsers(
145
+        dest="backup_command", help="Backup commands"
146
+    )
147
+
148
+    # Create backup
149
+    backup_create_parser = backup_subparsers.add_parser(
150
+        "create", help="Create a backup"
151
+    )
152
+    backup_create_parser.add_argument(
153
+        "-n", "--name", help="Backup name (timestamp used if not provided)"
154
+    )
155
+
156
+    # List backups
157
+    backup_list_parser = backup_subparsers.add_parser(
158
+        "list", help="List available backups"
159
+    )
160
+
161
+    # Restore backup
162
+    backup_restore_parser = backup_subparsers.add_parser(
163
+        "restore", help="Restore from backup"
164
+    )
165
+    backup_restore_parser.add_argument("name", help="Backup name or filename")
166
+
119167
     # Source command (for eval)
120168
     source_parser = subparsers.add_parser(
121169
         "source", help="Output source command for eval (for immediate loading)"
@@ -204,11 +252,29 @@ def main():
204252
                 commands.settings_set(args.key, args.value)
205253
             else:
206254
                 settings_parser.print_help()
255
+        elif args.command == "group":
256
+            if args.group_command == "create":
257
+                commands.group_create(args.name, args.description)
258
+            elif args.group_command == "rename":
259
+                commands.group_rename(args.old_name, args.new_name)
260
+            elif args.group_command == "remove":
261
+                commands.group_remove(args.name, args.force)
262
+            else:
263
+                group_parser.print_help()
264
+        elif args.command == "backup":
265
+            if args.backup_command == "create":
266
+                commands.backup_create(args.name)
267
+            elif args.backup_command == "list":
268
+                commands.backup_list()
269
+            elif args.backup_command == "restore":
270
+                commands.backup_restore(args.name)
271
+            else:
272
+                backup_parser.print_help()
207273
 
208274
     except KeyboardInterrupt:
209275
         logger.debug("Operation cancelled by user")
210276
         print("\nCancelled")
211
-        sys.exit(1)
277
+        sys.exit(2)  # Use exit code 2 for user cancellation
212278
     except Exception as e:
213279
         if args.debug:
214280
             logger.exception("Unhandled exception")
src/shtick/commands.pymodified
@@ -1,5 +1,5 @@
11
 """
2
-Command implementations for shtick CLI - REFACTORED to use ShtickManager
2
+Command implementations for shtick CLI - FIXED WITH CONSISTENT RETURN STATUSES
33
 """
44
 
55
 import os
@@ -16,7 +16,7 @@ logger = logging.getLogger("shtick")
1616
 
1717
 
1818
 class ShtickCommands:
19
-    """Central command handler for shtick operations - now using ShtickManager"""
19
+    """Central command handler for shtick operations - with consistent return codes"""
2020
 
2121
     def __init__(self, debug: bool = False):
2222
         # Set up logging based on debug flag
@@ -29,6 +29,17 @@ class ShtickCommands:
2929
 
3030
         self.manager = ShtickManager(debug=debug)
3131
 
32
+    def _exit_error(self, message: str, code: int = 1):
33
+        """Print error message and exit with given code"""
34
+        print(f"Error: {message}")
35
+        sys.exit(code)
36
+
37
+    def _exit_success(self, message: str = None, code: int = 0):
38
+        """Print optional success message and exit with code"""
39
+        if message:
40
+            print(message)
41
+        sys.exit(code)
42
+
3243
     def get_current_shell(self) -> Optional[str]:
3344
         """Use cached shell detection from Config"""
3445
         return Config.get_current_shell()
@@ -182,40 +193,43 @@ class ShtickCommands:
182193
             logger.error(f"Failed to create {primary_config}: {e}")
183194
             return False
184195
 
185
-    # Command implementations - now using ShtickManager
196
+    # Command implementations - now with consistent return statuses
186197
     def generate(self, config_path: str = None, terse: bool = False):
187198
         """Generate shell files from config"""
188199
         try:
189200
             if config_path:
190
-                # Validate path for security
191
-                validated_path = validate_config_path(config_path)
201
+                # Validate path for security with relaxed rules for generate
202
+                validated_path = validate_config_path(config_path, for_generate=True)
203
+
204
+                # Warn about custom config behavior
205
+                if not terse:
206
+                    print("Note: Generating from custom config file")
207
+                    print("This will overwrite files but won't affect active groups")
208
+
192209
                 # Create a new manager with custom config path
193210
                 manager = ShtickManager(config_path=validated_path)
194211
             else:
195212
                 manager = self.manager
196213
 
197214
             success = manager.generate_shell_files()
198
-            if success and not terse:
199
-                self.check_shell_integration()
200
-            elif not success:
201
-                print("Error: Failed to generate shell files")
202
-                sys.exit(1)
215
+            if success:
216
+                if not terse:
217
+                    self.check_shell_integration()
218
+                self._exit_success()  # Explicit success
219
+            else:
220
+                self._exit_error("Failed to generate shell files")
203221
 
204222
         except FileNotFoundError as e:
205
-            print(f"Error: {e}")
206
-            print(f"Create a config file first")
207
-            sys.exit(1)
223
+            self._exit_error(f"{e}\nCreate a config file first")
208224
         except Exception as e:
209
-            print(f"Error: {e}")
210
-            sys.exit(1)
225
+            self._exit_error(str(e))
211226
 
212227
     def add_item(self, item_type: str, group: str, assignment: str):
213228
         """Add an item to a specific group"""
214229
         try:
215230
             key, value = self.validate_assignment(assignment)
216231
         except ValueError as e:
217
-            print(f"Error: {e}")
218
-            sys.exit(1)
232
+            self._exit_error(str(e))
219233
 
220234
         # Dispatch to appropriate manager method
221235
         if item_type == "alias":
@@ -225,8 +239,7 @@ class ShtickCommands:
225239
         elif item_type == "function":
226240
             success = self.manager.add_function(key, value, group)
227241
         else:
228
-            print(f"Error: Unknown item type '{item_type}'")
229
-            sys.exit(1)
242
+            self._exit_error(f"Unknown item type '{item_type}'")
230243
 
231244
         if success:
232245
             print(f"✓ Added {item_type} '{key}' = '{value}' to group '{group}'")
@@ -236,17 +249,16 @@ class ShtickCommands:
236249
                 and group in self.manager.get_active_groups()
237250
             ):
238251
                 self.offer_auto_source()
252
+            self._exit_success()  # Explicit success
239253
         else:
240
-            print(f"Error: Failed to add {item_type}")
241
-            sys.exit(1)
254
+            self._exit_error(f"Failed to add {item_type}")
242255
 
243256
     def add_persistent(self, item_type: str, assignment: str):
244257
         """Add an item to the persistent group"""
245258
         try:
246259
             key, value = self.validate_assignment(assignment)
247260
         except ValueError as e:
248
-            print(f"Error: {e}")
249
-            sys.exit(1)
261
+            self._exit_error(str(e))
250262
 
251263
         is_first_time = not os.path.exists(Config.get_default_config_path())
252264
 
@@ -258,8 +270,7 @@ class ShtickCommands:
258270
         elif item_type == "function":
259271
             success = self.manager.add_persistent_function(key, value)
260272
         else:
261
-            print(f"Error: Unknown item type '{item_type}'")
262
-            sys.exit(1)
273
+            self._exit_error(f"Unknown item type '{item_type}'")
263274
 
264275
         if success:
265276
             print(
@@ -271,9 +282,10 @@ class ShtickCommands:
271282
             if is_first_time:
272283
                 print("\n🎉 Welcome to shtick!")
273284
                 self.check_shell_integration()
285
+
286
+            self._exit_success()  # Explicit success
274287
         else:
275
-            print(f"Error: Failed to add {item_type}")
276
-            sys.exit(1)
288
+            self._exit_error(f"Failed to add {item_type}")
277289
 
278290
     def remove_item(self, item_type: str, group: str, search: str):
279291
         """Remove an item from a group"""
@@ -290,12 +302,12 @@ class ShtickCommands:
290302
                 print(
291303
                     f"No {item_type} items matching '{search}' found in group '{group}'"
292304
                 )
293
-                return
305
+                self._exit_success()  # Not an error - nothing to remove
294306
 
295307
             # Handle single vs multiple matches
296308
             item_to_remove = self._select_item_to_remove(matches)
297309
             if not item_to_remove:
298
-                return
310
+                self._exit_success()  # User cancelled - not an error
299311
 
300312
             # Dispatch to appropriate manager method
301313
             if item_type == "alias":
@@ -305,20 +317,19 @@ class ShtickCommands:
305317
             elif item_type == "function":
306318
                 success = self.manager.remove_function(item_to_remove, group)
307319
             else:
308
-                print(f"Error: Unknown item type '{item_type}'")
309
-                return
320
+                self._exit_error(f"Unknown item type '{item_type}'")
310321
 
311322
             if success:
312323
                 print(f"✓ Removed {item_type} '{item_to_remove}' from group '{group}'")
313324
                 # Offer to source if group is active
314325
                 if group == "persistent" or group in self.manager.get_active_groups():
315326
                     self.offer_auto_source()
327
+                self._exit_success()  # Explicit success
316328
             else:
317
-                print(f"Failed to remove {item_type} '{item_to_remove}'")
329
+                self._exit_error(f"Failed to remove {item_type} '{item_to_remove}'")
318330
 
319331
         except Exception as e:
320
-            print(f"Error: {e}")
321
-            sys.exit(1)
332
+            self._exit_error(str(e))
322333
 
323334
     def _select_item_to_remove(self, matches: List[str]) -> Optional[str]:
324335
         """Handle selection of item to remove from matches"""
@@ -349,27 +360,29 @@ class ShtickCommands:
349360
     def activate_group(self, group_name: str):
350361
         """Activate a group"""
351362
         if group_name == "persistent":
352
-            print(
353
-                "Error: 'persistent' group is always active and cannot be manually activated"
363
+            self._exit_error(
364
+                "'persistent' group is always active and cannot be manually activated"
354365
             )
355
-            return
356366
 
357367
         success = self.manager.activate_group(group_name)
358368
         if success:
359369
             print(f"✓ Activated group '{group_name}'")
360370
             print("Changes are now active in new shell sessions")
361371
             self.offer_auto_source()
372
+            self._exit_success()  # Explicit success
362373
         else:
363
-            print(f"Error: Group '{group_name}' not found in configuration")
364374
             available = self.manager.get_groups()
365375
             if available:
366
-                print(f"Available groups: {', '.join(available)}")
376
+                self._exit_error(
377
+                    f"Group '{group_name}' not found. Available groups: {', '.join(available)}"
378
+                )
379
+            else:
380
+                self._exit_error(f"Group '{group_name}' not found in configuration")
367381
 
368382
     def deactivate_group(self, group_name: str):
369383
         """Deactivate a group"""
370384
         if group_name == "persistent":
371
-            print("Error: 'persistent' group cannot be deactivated")
372
-            return
385
+            self._exit_error("'persistent' group cannot be deactivated")
373386
 
374387
         success = self.manager.deactivate_group(group_name)
375388
         if success:
@@ -378,6 +391,9 @@ class ShtickCommands:
378391
             self.offer_auto_source()
379392
         else:
380393
             print(f"Group '{group_name}' was not active")
394
+            # Not an error - deactivating inactive group is idempotent
395
+
396
+        self._exit_success()  # Always exit success for idempotent operation
381397
 
382398
     def source_command(self, shell: str = None):
383399
         """Output source command for eval"""
@@ -397,6 +413,7 @@ class ShtickCommands:
397413
 
398414
         # Output the source command that can be eval'd
399415
         print(f"source {loader_path}")
416
+        self._exit_success()  # Explicit success
400417
 
401418
     # Settings commands
402419
     def settings_init(self):
@@ -414,14 +431,15 @@ class ShtickCommands:
414431
                 )
415432
                 if response not in ["y", "yes"]:
416433
                     print("Cancelled")
417
-                    return
434
+                    self._exit_success()  # User cancelled - not an error
418435
             except (KeyboardInterrupt, EOFError):
419436
                 print("\nCancelled")
420
-                return
437
+                self._exit_error("Operation cancelled by user", code=2)
421438
 
422439
         settings.create_default_settings_file()
423440
         print(f"✓ Created settings file at {settings._settings_path}")
424441
         print("\nYou can now customize your shtick behavior by editing this file.")
442
+        self._exit_success()  # Explicit success
425443
 
426444
     def settings_show(self):
427445
         """Show current settings"""
@@ -453,6 +471,8 @@ class ShtickCommands:
453471
             print("(No settings file found - using defaults)")
454472
             print("Run 'shtick settings init' to create one")
455473
 
474
+        self._exit_success()  # Explicit success
475
+
456476
     def settings_set(self, key: str, value: str):
457477
         """Set a specific setting value"""
458478
         from shtick.settings import Settings
@@ -462,19 +482,17 @@ class ShtickCommands:
462482
         # Parse the key (e.g., "generation.shells")
463483
         parts = key.split(".")
464484
         if len(parts) != 2:
465
-            print(
466
-                f"Error: Invalid key format. Use 'section.key' (e.g., 'generation.shells')"
485
+            self._exit_error(
486
+                "Invalid key format. Use 'section.key' (e.g., 'generation.shells')"
467487
             )
468
-            sys.exit(1)
469488
 
470489
         section, setting_key = parts
471490
 
472491
         # Validate section
473492
         if section not in ["generation", "behavior", "performance"]:
474
-            print(
475
-                f"Error: Invalid section '{section}'. Must be one of: generation, behavior, performance"
493
+            self._exit_error(
494
+                f"Invalid section '{section}'. Must be one of: generation, behavior, performance"
476495
             )
477
-            sys.exit(1)
478496
 
479497
         # Get the section object
480498
         section_obj = getattr(settings, section)
@@ -483,7 +501,7 @@ class ShtickCommands:
483501
         if not hasattr(section_obj, setting_key):
484502
             print(f"Error: Invalid key '{setting_key}' for section '{section}'")
485503
             print(f"Valid keys: {', '.join(vars(section_obj).keys())}")
486
-            sys.exit(1)
504
+            self._exit_error(f"Invalid key '{setting_key}'")
487505
 
488506
         # Parse the value based on type
489507
         current_value = getattr(section_obj, setting_key)
@@ -517,14 +535,109 @@ class ShtickCommands:
517535
                 # String value
518536
                 parsed_value = value
519537
         except Exception as e:
520
-            print(f"Error parsing value: {e}")
521
-            sys.exit(1)
538
+            self._exit_error(f"Error parsing value: {e}")
522539
 
523540
         # Set the value
524541
         setattr(section_obj, setting_key, parsed_value)
525542
 
526543
         # Save settings
527
-        settings.save()
544
+        try:
545
+            settings.save()
546
+            print(f"✓ Set {key} = {parsed_value}")
547
+            print(f"Settings saved to {settings._settings_path}")
548
+            self._exit_success()  # Explicit success
549
+        except Exception as e:
550
+            self._exit_error(f"Failed to save settings: {e}")
551
+
552
+    # Group management commands
553
+    def group_create(self, name: str, description: str = None):
554
+        """Create a new group"""
555
+        try:
556
+            # Check if group already exists
557
+            if self.manager.get_groups() and name in self.manager.get_groups():
558
+                self._exit_error(f"Group '{name}' already exists")
559
+
560
+            # Actually create the empty group
561
+            from shtick.config import Config, GroupData
562
+
563
+            config = self.manager._get_config()
564
+
565
+            # Add the new empty group
566
+            new_group = GroupData(name=name, aliases={}, env_vars={}, functions={})
567
+            config.groups.append(new_group)
568
+
569
+            # Save the config with the new empty group
570
+            config.save()
571
+
572
+            print(f"✓ Created group '{name}'")
573
+            print(f"\nAdd items to this group with:")
574
+            print(f"  shtick add alias {name} ll='ls -la'")
575
+            print(f"  shtick add env {name} DEBUG=1")
576
+            print(f"\nActivate with:")
577
+            print(f"  shtick activate {name}")
578
+
579
+            self._exit_success()
580
+        except Exception as e:
581
+            self._exit_error(str(e))
582
+
583
+    def group_rename(self, old_name: str, new_name: str):
584
+        """Rename a group"""
585
+        # This would require refactoring the config to support renaming
586
+        # For now, just indicate it's not implemented
587
+        self._exit_error("Group rename is not yet implemented")
588
+
589
+    def group_remove(self, name: str, force: bool = False):
590
+        """Remove a group"""
591
+        # This would require refactoring the config to support group removal
592
+        # For now, just indicate it's not implemented
593
+        self._exit_error("Group removal is not yet implemented")
594
+
595
+    # Backup commands
596
+    def backup_create(self, name: str = None):
597
+        """Create a backup"""
598
+        try:
599
+            backup_path = self.manager.backup_config(name)
600
+            print(f"✓ Created backup: {backup_path}")
601
+            self._exit_success()
602
+        except Exception as e:
603
+            self._exit_error(f"Failed to create backup: {e}")
604
+
605
+    def backup_list(self):
606
+        """List available backups"""
607
+        try:
608
+            backups = self.manager.list_backups()
609
+            if not backups:
610
+                print("No backups found")
611
+            else:
612
+                print("Available backups:")
613
+                for backup in backups:
614
+                    from datetime import datetime
615
+
616
+                    mtime = datetime.fromtimestamp(backup["modified"]).strftime(
617
+                        "%Y-%m-%d %H:%M:%S"
618
+                    )
619
+                    size_kb = backup["size"] / 1024
620
+                    print(f"  {backup['name']} ({size_kb:.1f} KB, modified: {mtime})")
621
+            self._exit_success()
622
+        except Exception as e:
623
+            self._exit_error(f"Failed to list backups: {e}")
624
+
625
+    def backup_restore(self, name: str):
626
+        """Restore from backup"""
627
+        try:
628
+            if self.manager.restore_backup(name):
629
+                print(f"✓ Restored from backup: {name}")
630
+                print("Run 'shtick generate' to regenerate shell files")
631
+                self._exit_success()
632
+            else:
633
+                self._exit_error(f"Backup '{name}' not found")
634
+        except Exception as e:
635
+            self._exit_error(f"Failed to restore backup: {e}")
636
+
528637
 
529
-        print(f"✓ Set {key} = {parsed_value}")
530
-        print(f"Settings saved to {settings._settings_path}")
638
+# Return code conventions:
639
+# 0 - Success
640
+# 1 - General error (invalid arguments, missing files, etc.)
641
+# 2 - User cancelled operation
642
+# 3 - Permission denied (currently not used, but reserved)
643
+# 4 - Resource not found when it should exist (currently not used, but reserved)
src/shtick/config.pymodified
@@ -75,42 +75,45 @@ def save_config_securely(config_path: str, groups) -> None:
7575
         data = {}
7676
         for group in groups:
7777
             group_data = {}
78
-            if group.aliases:
79
-                group_data["aliases"] = group.aliases
80
-            if group.env_vars:
81
-                group_data["env_vars"] = group.env_vars
82
-            if group.functions:
83
-                group_data["functions"] = group.functions
84
-            if group_data:
85
-                data[group.name] = group_data
78
+            # Always include the sections, even if empty
79
+            group_data["aliases"] = group.aliases if group.aliases else {}
80
+            group_data["env_vars"] = group.env_vars if group.env_vars else {}
81
+            group_data["functions"] = group.functions if group.functions else {}
82
+            data[group.name] = group_data
8683
 
8784
         with open(config_path, "wb") as f:
8885
             tomli_w.dump(data, f)
8986
 
9087
     except ImportError:
91
-        # Fallback with proper escaping
92
-        data = {}
93
-        for group in groups:
94
-            if group.aliases:
95
-                data[f"{group.name}.aliases"] = group.aliases
96
-            if group.env_vars:
97
-                data[f"{group.name}.env_vars"] = group.env_vars
98
-            if group.functions:
99
-                data[f"{group.name}.functions"] = group.functions
100
-
101
-        # Write TOML manually with proper escaping
88
+        # Enhanced fallback that writes proper nested TOML structure
10289
         with open(config_path, "w") as f:
103
-            # Sort sections for consistent output
104
-            for section in sorted(data.keys()):
105
-                items = data[section]
106
-                f.write(f"[{section}]\n")
107
-                # Sort items within section
108
-                for key in sorted(items.keys()):
109
-                    value = items[key]
110
-                    # Use proper TOML escaping
90
+            # Write each group
91
+            for group in groups:
92
+                # Write main group header
93
+                f.write(f"[{group.name}]\n")
94
+
95
+                # Write aliases section
96
+                f.write(f"[{group.name}.aliases]\n")
97
+                for key in sorted(group.aliases.keys()):
98
+                    value = group.aliases[key]
11199
                     escaped_value = escape_toml_value(value)
112100
                     f.write(f"{key} = {escaped_value}\n")
113
-                f.write("\n")
101
+
102
+                # Write env_vars section
103
+                f.write(f"\n[{group.name}.env_vars]\n")
104
+                for key in sorted(group.env_vars.keys()):
105
+                    value = group.env_vars[key]
106
+                    escaped_value = escape_toml_value(value)
107
+                    f.write(f"{key} = {escaped_value}\n")
108
+
109
+                # Write functions section
110
+                f.write(f"\n[{group.name}.functions]\n")
111
+                for key in sorted(group.functions.keys()):
112
+                    value = group.functions[key]
113
+                    escaped_value = escape_toml_value(value)
114
+                    f.write(f"{key} = {escaped_value}\n")
115
+
116
+                f.write("\n")  # Empty line between groups
114117
 
115118
 
116119
 @dataclass
@@ -335,7 +338,7 @@ class Config:
335338
                         f"Unknown or invalid section '{section_name}' in group '{group_name}'"
336339
                     )
337340
 
338
-            # Create GroupData object if group has any items
341
+            # Create GroupData object (allow empty groups)
339342
             new_group = GroupData(
340343
                 name=group_name,
341344
                 aliases=group_data["aliases"],
@@ -343,14 +346,16 @@ class Config:
343346
                 functions=group_data["functions"],
344347
             )
345348
 
349
+            # Always add the group, even if empty
350
+            self.groups.append(new_group)
351
+
346352
             if new_group.total_items > 0:
347
-                self.groups.append(new_group)
348353
                 logger.debug(
349354
                     f"Created group '{group_name}' with {len(group_data['aliases'])} aliases, "
350355
                     f"{len(group_data['env_vars'])} env_vars, {len(group_data['functions'])} functions"
351356
                 )
352357
             else:
353
-                logger.warning(f"Group '{group_name}' has no items, skipping")
358
+                logger.debug(f"Created empty group '{group_name}'")
354359
 
355360
         logger.debug(
356361
             f"Final groups loaded: {[g.name for g in self.groups]} (total: {len(self.groups)})"
@@ -358,6 +363,39 @@ class Config:
358363
 
359364
     def save(self) -> None:
360365
         """Save the current configuration back to TOML file with secure escaping"""
366
+        # Check if we should backup
367
+        from .settings import Settings
368
+
369
+        settings = Settings()
370
+
371
+        if settings.behavior.backup_on_save and os.path.exists(self.config_path):
372
+            # Create automatic backup
373
+            from datetime import datetime
374
+
375
+            backup_dir = os.path.join(os.path.dirname(self.config_path), "backups")
376
+            os.makedirs(backup_dir, exist_ok=True)
377
+
378
+            timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
379
+            backup_path = os.path.join(backup_dir, f"config_auto_{timestamp}.toml")
380
+
381
+            import shutil
382
+
383
+            shutil.copy2(self.config_path, backup_path)
384
+            logger.debug(f"Created automatic backup: {backup_path}")
385
+
386
+            # Clean up old auto backups (keep last 10)
387
+            auto_backups = sorted(
388
+                [
389
+                    f
390
+                    for f in os.listdir(backup_dir)
391
+                    if f.startswith("config_auto_") and f.endswith(".toml")
392
+                ]
393
+            )
394
+            if len(auto_backups) > 10:
395
+                for old_backup in auto_backups[:-10]:
396
+                    os.remove(os.path.join(backup_dir, old_backup))
397
+
398
+        # Save normally
361399
         save_config_securely(self.config_path, self.groups)
362400
 
363401
     def get_group(self, group_name: str) -> Optional[GroupData]:
src/shtick/security.pymodified
@@ -1,5 +1,5 @@
11
 """
2
-Security validation functions for shtick
2
+Security validation functions for shtick - FIXED FOR GENERATE COMMAND
33
 """
44
 
55
 import os
@@ -58,12 +58,13 @@ def validate_value(value: str, max_length: int = 4096) -> None:
5858
         raise ValueError(f"Value too long: maximum {max_length} characters")
5959
 
6060
 
61
-def validate_config_path(path: str) -> str:
61
+def validate_config_path(path: str, for_generate: bool = False) -> str:
6262
     """
6363
     Validate and sanitize config path for security.
6464
 
6565
     Args:
6666
         path: Path to validate
67
+        for_generate: If True, use relaxed validation for generate command
6768
 
6869
     Returns:
6970
         Validated absolute path
@@ -85,23 +86,46 @@ def validate_config_path(path: str) -> str:
8586
         if ".." in path:
8687
             raise ValueError("Directory traversal detected")
8788
 
89
+        # Ensure .toml extension
90
+        if resolved.suffix != ".toml":
91
+            raise ValueError("Config file must have .toml extension")
92
+
93
+        # For generate command, use relaxed validation
94
+        if for_generate:
95
+            # Just ensure the file exists and isn't in system directories
96
+            if not resolved.exists():
97
+                raise ValueError("Config file not found")
98
+
99
+            # Still block system directories
100
+            if any(
101
+                resolved_str.startswith(forbidden)
102
+                for forbidden in FORBIDDEN_SYSTEM_PATHS
103
+            ):
104
+                raise ValueError("Access to system directories is forbidden")
105
+
106
+            return resolved_str
107
+
108
+        # For other commands, use strict validation
88109
         # Block system directories
89110
         if any(
90111
             resolved_str.startswith(forbidden) for forbidden in FORBIDDEN_SYSTEM_PATHS
91112
         ):
92
-            raise ValueError(f"Access to system directories is forbidden")
113
+            raise ValueError("Access to system directories is forbidden")
93114
 
94115
         # Ensure it's under user's home or current directory
95116
         home = Path.home()
96117
         cwd = Path.cwd()
97118
 
119
+        # Check both the original home and current home (for tests)
120
+        original_home = os.environ.get("SHTICK_ORIGINAL_HOME")
121
+        if original_home:
122
+            original_home_path = Path(original_home)
123
+            if resolved.is_relative_to(original_home_path):
124
+                return resolved_str
125
+
98126
         if not (resolved.is_relative_to(home) or resolved.is_relative_to(cwd)):
99127
             raise ValueError("Config path must be under home or current directory")
100128
 
101
-        # Ensure .toml extension
102
-        if resolved.suffix != ".toml":
103
-            raise ValueError("Config file must have .toml extension")
104
-
105129
         return resolved_str
106130
 
107131
     except Exception as e:
src/shtick/shtick.pymodified
@@ -592,6 +592,94 @@ class ShtickManager:
592592
             logger.error(f"Error generating shell files: {e}")
593593
             return False
594594
 
595
+    def backup_config(self, backup_name: str = None) -> str:
596
+        """Create a backup of current configuration"""
597
+        import shutil
598
+        from datetime import datetime
599
+
600
+        config = self._get_config()
601
+        backup_dir = os.path.join(os.path.dirname(config.config_path), "backups")
602
+        os.makedirs(backup_dir, exist_ok=True)
603
+
604
+        if backup_name:
605
+            backup_filename = f"config_{backup_name}.toml"
606
+        else:
607
+            timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
608
+            backup_filename = f"config_backup_{timestamp}.toml"
609
+
610
+        backup_path = os.path.join(backup_dir, backup_filename)
611
+
612
+        # Copy current config
613
+        if os.path.exists(config.config_path):
614
+            shutil.copy2(config.config_path, backup_path)
615
+            return backup_path
616
+        else:
617
+            raise FileNotFoundError("No config file to backup")
618
+
619
+    def list_backups(self) -> List[Dict[str, str]]:
620
+        """List all available backups"""
621
+        config = self._get_config()
622
+        backup_dir = os.path.join(os.path.dirname(config.config_path), "backups")
623
+
624
+        if not os.path.exists(backup_dir):
625
+            return []
626
+
627
+        backups = []
628
+        for file in sorted(os.listdir(backup_dir), reverse=True):
629
+            if file.endswith(".toml"):
630
+                file_path = os.path.join(backup_dir, file)
631
+                stat = os.stat(file_path)
632
+                backups.append(
633
+                    {
634
+                        "name": file,
635
+                        "path": file_path,
636
+                        "size": stat.st_size,
637
+                        "modified": stat.st_mtime,
638
+                    }
639
+                )
640
+
641
+        return backups
642
+
643
+    def restore_backup(self, backup_name: str) -> bool:
644
+        """Restore configuration from a backup"""
645
+        import shutil
646
+
647
+        config = self._get_config()
648
+        backup_dir = os.path.join(os.path.dirname(config.config_path), "backups")
649
+
650
+        # Find backup file - try multiple naming patterns
651
+        backup_path = None
652
+        possible_names = [
653
+            backup_name,  # exact name
654
+            f"{backup_name}.toml",  # add extension
655
+            f"config_{backup_name}",  # add prefix
656
+            f"config_{backup_name}.toml",  # add both
657
+        ]
658
+
659
+        for name in possible_names:
660
+            full_path = os.path.join(backup_dir, name)
661
+            if os.path.exists(full_path):
662
+                backup_path = full_path
663
+                break
664
+
665
+        if not backup_path:
666
+            return False
667
+
668
+        # Create backup of current before restoring
669
+        try:
670
+            self.backup_config("before_restore")
671
+        except:
672
+            pass  # Current config might not exist
673
+
674
+        # Restore
675
+        shutil.copy2(backup_path, config.config_path)
676
+
677
+        # Reload config and regenerate
678
+        self._load_config(create_if_missing=False)
679
+        self.generate_shell_files()
680
+
681
+        return True
682
+
595683
     def get_source_command(self, shell: Optional[str] = None) -> Optional[str]:
596684
         """
597685
         Get the source command for loading shtick in current session.