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():
116
         help="Show one shell per line instead of columns",
116
         help="Show one shell per line instead of columns",
117
     )
117
     )
118
 
118
 
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
+
119
     # Source command (for eval)
167
     # Source command (for eval)
120
     source_parser = subparsers.add_parser(
168
     source_parser = subparsers.add_parser(
121
         "source", help="Output source command for eval (for immediate loading)"
169
         "source", help="Output source command for eval (for immediate loading)"
@@ -204,11 +252,29 @@ def main():
204
                 commands.settings_set(args.key, args.value)
252
                 commands.settings_set(args.key, args.value)
205
             else:
253
             else:
206
                 settings_parser.print_help()
254
                 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()
207
 
273
 
208
     except KeyboardInterrupt:
274
     except KeyboardInterrupt:
209
         logger.debug("Operation cancelled by user")
275
         logger.debug("Operation cancelled by user")
210
         print("\nCancelled")
276
         print("\nCancelled")
211
-        sys.exit(1)
277
+        sys.exit(2)  # Use exit code 2 for user cancellation
212
     except Exception as e:
278
     except Exception as e:
213
         if args.debug:
279
         if args.debug:
214
             logger.exception("Unhandled exception")
280
             logger.exception("Unhandled exception")
src/shtick/commands.pymodified
@@ -1,5 +1,5 @@
1
 """
1
 """
2
-Command implementations for shtick CLI - REFACTORED to use ShtickManager
2
+Command implementations for shtick CLI - FIXED WITH CONSISTENT RETURN STATUSES
3
 """
3
 """
4
 
4
 
5
 import os
5
 import os
@@ -16,7 +16,7 @@ logger = logging.getLogger("shtick")
16
 
16
 
17
 
17
 
18
 class ShtickCommands:
18
 class ShtickCommands:
19
-    """Central command handler for shtick operations - now using ShtickManager"""
19
+    """Central command handler for shtick operations - with consistent return codes"""
20
 
20
 
21
     def __init__(self, debug: bool = False):
21
     def __init__(self, debug: bool = False):
22
         # Set up logging based on debug flag
22
         # Set up logging based on debug flag
@@ -29,6 +29,17 @@ class ShtickCommands:
29
 
29
 
30
         self.manager = ShtickManager(debug=debug)
30
         self.manager = ShtickManager(debug=debug)
31
 
31
 
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
+
32
     def get_current_shell(self) -> Optional[str]:
43
     def get_current_shell(self) -> Optional[str]:
33
         """Use cached shell detection from Config"""
44
         """Use cached shell detection from Config"""
34
         return Config.get_current_shell()
45
         return Config.get_current_shell()
@@ -182,40 +193,43 @@ class ShtickCommands:
182
             logger.error(f"Failed to create {primary_config}: {e}")
193
             logger.error(f"Failed to create {primary_config}: {e}")
183
             return False
194
             return False
184
 
195
 
185
-    # Command implementations - now using ShtickManager
196
+    # Command implementations - now with consistent return statuses
186
     def generate(self, config_path: str = None, terse: bool = False):
197
     def generate(self, config_path: str = None, terse: bool = False):
187
         """Generate shell files from config"""
198
         """Generate shell files from config"""
188
         try:
199
         try:
189
             if config_path:
200
             if config_path:
190
-                # Validate path for security
201
+                # Validate path for security with relaxed rules for generate
191
-                validated_path = validate_config_path(config_path)
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
+
192
                 # Create a new manager with custom config path
209
                 # Create a new manager with custom config path
193
                 manager = ShtickManager(config_path=validated_path)
210
                 manager = ShtickManager(config_path=validated_path)
194
             else:
211
             else:
195
                 manager = self.manager
212
                 manager = self.manager
196
 
213
 
197
             success = manager.generate_shell_files()
214
             success = manager.generate_shell_files()
198
-            if success and not terse:
215
+            if success:
199
-                self.check_shell_integration()
216
+                if not terse:
200
-            elif not success:
217
+                    self.check_shell_integration()
201
-                print("Error: Failed to generate shell files")
218
+                self._exit_success()  # Explicit success
202
-                sys.exit(1)
219
+            else:
220
+                self._exit_error("Failed to generate shell files")
203
 
221
 
204
         except FileNotFoundError as e:
222
         except FileNotFoundError as e:
205
-            print(f"Error: {e}")
223
+            self._exit_error(f"{e}\nCreate a config file first")
206
-            print(f"Create a config file first")
207
-            sys.exit(1)
208
         except Exception as e:
224
         except Exception as e:
209
-            print(f"Error: {e}")
225
+            self._exit_error(str(e))
210
-            sys.exit(1)
211
 
226
 
212
     def add_item(self, item_type: str, group: str, assignment: str):
227
     def add_item(self, item_type: str, group: str, assignment: str):
213
         """Add an item to a specific group"""
228
         """Add an item to a specific group"""
214
         try:
229
         try:
215
             key, value = self.validate_assignment(assignment)
230
             key, value = self.validate_assignment(assignment)
216
         except ValueError as e:
231
         except ValueError as e:
217
-            print(f"Error: {e}")
232
+            self._exit_error(str(e))
218
-            sys.exit(1)
219
 
233
 
220
         # Dispatch to appropriate manager method
234
         # Dispatch to appropriate manager method
221
         if item_type == "alias":
235
         if item_type == "alias":
@@ -225,8 +239,7 @@ class ShtickCommands:
225
         elif item_type == "function":
239
         elif item_type == "function":
226
             success = self.manager.add_function(key, value, group)
240
             success = self.manager.add_function(key, value, group)
227
         else:
241
         else:
228
-            print(f"Error: Unknown item type '{item_type}'")
242
+            self._exit_error(f"Unknown item type '{item_type}'")
229
-            sys.exit(1)
230
 
243
 
231
         if success:
244
         if success:
232
             print(f"✓ Added {item_type} '{key}' = '{value}' to group '{group}'")
245
             print(f"✓ Added {item_type} '{key}' = '{value}' to group '{group}'")
@@ -236,17 +249,16 @@ class ShtickCommands:
236
                 and group in self.manager.get_active_groups()
249
                 and group in self.manager.get_active_groups()
237
             ):
250
             ):
238
                 self.offer_auto_source()
251
                 self.offer_auto_source()
252
+            self._exit_success()  # Explicit success
239
         else:
253
         else:
240
-            print(f"Error: Failed to add {item_type}")
254
+            self._exit_error(f"Failed to add {item_type}")
241
-            sys.exit(1)
242
 
255
 
243
     def add_persistent(self, item_type: str, assignment: str):
256
     def add_persistent(self, item_type: str, assignment: str):
244
         """Add an item to the persistent group"""
257
         """Add an item to the persistent group"""
245
         try:
258
         try:
246
             key, value = self.validate_assignment(assignment)
259
             key, value = self.validate_assignment(assignment)
247
         except ValueError as e:
260
         except ValueError as e:
248
-            print(f"Error: {e}")
261
+            self._exit_error(str(e))
249
-            sys.exit(1)
250
 
262
 
251
         is_first_time = not os.path.exists(Config.get_default_config_path())
263
         is_first_time = not os.path.exists(Config.get_default_config_path())
252
 
264
 
@@ -258,8 +270,7 @@ class ShtickCommands:
258
         elif item_type == "function":
270
         elif item_type == "function":
259
             success = self.manager.add_persistent_function(key, value)
271
             success = self.manager.add_persistent_function(key, value)
260
         else:
272
         else:
261
-            print(f"Error: Unknown item type '{item_type}'")
273
+            self._exit_error(f"Unknown item type '{item_type}'")
262
-            sys.exit(1)
263
 
274
 
264
         if success:
275
         if success:
265
             print(
276
             print(
@@ -271,9 +282,10 @@ class ShtickCommands:
271
             if is_first_time:
282
             if is_first_time:
272
                 print("\n🎉 Welcome to shtick!")
283
                 print("\n🎉 Welcome to shtick!")
273
                 self.check_shell_integration()
284
                 self.check_shell_integration()
285
+
286
+            self._exit_success()  # Explicit success
274
         else:
287
         else:
275
-            print(f"Error: Failed to add {item_type}")
288
+            self._exit_error(f"Failed to add {item_type}")
276
-            sys.exit(1)
277
 
289
 
278
     def remove_item(self, item_type: str, group: str, search: str):
290
     def remove_item(self, item_type: str, group: str, search: str):
279
         """Remove an item from a group"""
291
         """Remove an item from a group"""
@@ -290,12 +302,12 @@ class ShtickCommands:
290
                 print(
302
                 print(
291
                     f"No {item_type} items matching '{search}' found in group '{group}'"
303
                     f"No {item_type} items matching '{search}' found in group '{group}'"
292
                 )
304
                 )
293
-                return
305
+                self._exit_success()  # Not an error - nothing to remove
294
 
306
 
295
             # Handle single vs multiple matches
307
             # Handle single vs multiple matches
296
             item_to_remove = self._select_item_to_remove(matches)
308
             item_to_remove = self._select_item_to_remove(matches)
297
             if not item_to_remove:
309
             if not item_to_remove:
298
-                return
310
+                self._exit_success()  # User cancelled - not an error
299
 
311
 
300
             # Dispatch to appropriate manager method
312
             # Dispatch to appropriate manager method
301
             if item_type == "alias":
313
             if item_type == "alias":
@@ -305,20 +317,19 @@ class ShtickCommands:
305
             elif item_type == "function":
317
             elif item_type == "function":
306
                 success = self.manager.remove_function(item_to_remove, group)
318
                 success = self.manager.remove_function(item_to_remove, group)
307
             else:
319
             else:
308
-                print(f"Error: Unknown item type '{item_type}'")
320
+                self._exit_error(f"Unknown item type '{item_type}'")
309
-                return
310
 
321
 
311
             if success:
322
             if success:
312
                 print(f"✓ Removed {item_type} '{item_to_remove}' from group '{group}'")
323
                 print(f"✓ Removed {item_type} '{item_to_remove}' from group '{group}'")
313
                 # Offer to source if group is active
324
                 # Offer to source if group is active
314
                 if group == "persistent" or group in self.manager.get_active_groups():
325
                 if group == "persistent" or group in self.manager.get_active_groups():
315
                     self.offer_auto_source()
326
                     self.offer_auto_source()
327
+                self._exit_success()  # Explicit success
316
             else:
328
             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}'")
318
 
330
 
319
         except Exception as e:
331
         except Exception as e:
320
-            print(f"Error: {e}")
332
+            self._exit_error(str(e))
321
-            sys.exit(1)
322
 
333
 
323
     def _select_item_to_remove(self, matches: List[str]) -> Optional[str]:
334
     def _select_item_to_remove(self, matches: List[str]) -> Optional[str]:
324
         """Handle selection of item to remove from matches"""
335
         """Handle selection of item to remove from matches"""
@@ -349,27 +360,29 @@ class ShtickCommands:
349
     def activate_group(self, group_name: str):
360
     def activate_group(self, group_name: str):
350
         """Activate a group"""
361
         """Activate a group"""
351
         if group_name == "persistent":
362
         if group_name == "persistent":
352
-            print(
363
+            self._exit_error(
353
-                "Error: 'persistent' group is always active and cannot be manually activated"
364
+                "'persistent' group is always active and cannot be manually activated"
354
             )
365
             )
355
-            return
356
 
366
 
357
         success = self.manager.activate_group(group_name)
367
         success = self.manager.activate_group(group_name)
358
         if success:
368
         if success:
359
             print(f"✓ Activated group '{group_name}'")
369
             print(f"✓ Activated group '{group_name}'")
360
             print("Changes are now active in new shell sessions")
370
             print("Changes are now active in new shell sessions")
361
             self.offer_auto_source()
371
             self.offer_auto_source()
372
+            self._exit_success()  # Explicit success
362
         else:
373
         else:
363
-            print(f"Error: Group '{group_name}' not found in configuration")
364
             available = self.manager.get_groups()
374
             available = self.manager.get_groups()
365
             if available:
375
             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")
367
 
381
 
368
     def deactivate_group(self, group_name: str):
382
     def deactivate_group(self, group_name: str):
369
         """Deactivate a group"""
383
         """Deactivate a group"""
370
         if group_name == "persistent":
384
         if group_name == "persistent":
371
-            print("Error: 'persistent' group cannot be deactivated")
385
+            self._exit_error("'persistent' group cannot be deactivated")
372
-            return
373
 
386
 
374
         success = self.manager.deactivate_group(group_name)
387
         success = self.manager.deactivate_group(group_name)
375
         if success:
388
         if success:
@@ -378,6 +391,9 @@ class ShtickCommands:
378
             self.offer_auto_source()
391
             self.offer_auto_source()
379
         else:
392
         else:
380
             print(f"Group '{group_name}' was not active")
393
             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
381
 
397
 
382
     def source_command(self, shell: str = None):
398
     def source_command(self, shell: str = None):
383
         """Output source command for eval"""
399
         """Output source command for eval"""
@@ -397,6 +413,7 @@ class ShtickCommands:
397
 
413
 
398
         # Output the source command that can be eval'd
414
         # Output the source command that can be eval'd
399
         print(f"source {loader_path}")
415
         print(f"source {loader_path}")
416
+        self._exit_success()  # Explicit success
400
 
417
 
401
     # Settings commands
418
     # Settings commands
402
     def settings_init(self):
419
     def settings_init(self):
@@ -414,14 +431,15 @@ class ShtickCommands:
414
                 )
431
                 )
415
                 if response not in ["y", "yes"]:
432
                 if response not in ["y", "yes"]:
416
                     print("Cancelled")
433
                     print("Cancelled")
417
-                    return
434
+                    self._exit_success()  # User cancelled - not an error
418
             except (KeyboardInterrupt, EOFError):
435
             except (KeyboardInterrupt, EOFError):
419
                 print("\nCancelled")
436
                 print("\nCancelled")
420
-                return
437
+                self._exit_error("Operation cancelled by user", code=2)
421
 
438
 
422
         settings.create_default_settings_file()
439
         settings.create_default_settings_file()
423
         print(f"✓ Created settings file at {settings._settings_path}")
440
         print(f"✓ Created settings file at {settings._settings_path}")
424
         print("\nYou can now customize your shtick behavior by editing this file.")
441
         print("\nYou can now customize your shtick behavior by editing this file.")
442
+        self._exit_success()  # Explicit success
425
 
443
 
426
     def settings_show(self):
444
     def settings_show(self):
427
         """Show current settings"""
445
         """Show current settings"""
@@ -453,6 +471,8 @@ class ShtickCommands:
453
             print("(No settings file found - using defaults)")
471
             print("(No settings file found - using defaults)")
454
             print("Run 'shtick settings init' to create one")
472
             print("Run 'shtick settings init' to create one")
455
 
473
 
474
+        self._exit_success()  # Explicit success
475
+
456
     def settings_set(self, key: str, value: str):
476
     def settings_set(self, key: str, value: str):
457
         """Set a specific setting value"""
477
         """Set a specific setting value"""
458
         from shtick.settings import Settings
478
         from shtick.settings import Settings
@@ -462,19 +482,17 @@ class ShtickCommands:
462
         # Parse the key (e.g., "generation.shells")
482
         # Parse the key (e.g., "generation.shells")
463
         parts = key.split(".")
483
         parts = key.split(".")
464
         if len(parts) != 2:
484
         if len(parts) != 2:
465
-            print(
485
+            self._exit_error(
466
-                f"Error: Invalid key format. Use 'section.key' (e.g., 'generation.shells')"
486
+                "Invalid key format. Use 'section.key' (e.g., 'generation.shells')"
467
             )
487
             )
468
-            sys.exit(1)
469
 
488
 
470
         section, setting_key = parts
489
         section, setting_key = parts
471
 
490
 
472
         # Validate section
491
         # Validate section
473
         if section not in ["generation", "behavior", "performance"]:
492
         if section not in ["generation", "behavior", "performance"]:
474
-            print(
493
+            self._exit_error(
475
-                f"Error: Invalid section '{section}'. Must be one of: generation, behavior, performance"
494
+                f"Invalid section '{section}'. Must be one of: generation, behavior, performance"
476
             )
495
             )
477
-            sys.exit(1)
478
 
496
 
479
         # Get the section object
497
         # Get the section object
480
         section_obj = getattr(settings, section)
498
         section_obj = getattr(settings, section)
@@ -483,7 +501,7 @@ class ShtickCommands:
483
         if not hasattr(section_obj, setting_key):
501
         if not hasattr(section_obj, setting_key):
484
             print(f"Error: Invalid key '{setting_key}' for section '{section}'")
502
             print(f"Error: Invalid key '{setting_key}' for section '{section}'")
485
             print(f"Valid keys: {', '.join(vars(section_obj).keys())}")
503
             print(f"Valid keys: {', '.join(vars(section_obj).keys())}")
486
-            sys.exit(1)
504
+            self._exit_error(f"Invalid key '{setting_key}'")
487
 
505
 
488
         # Parse the value based on type
506
         # Parse the value based on type
489
         current_value = getattr(section_obj, setting_key)
507
         current_value = getattr(section_obj, setting_key)
@@ -517,14 +535,109 @@ class ShtickCommands:
517
                 # String value
535
                 # String value
518
                 parsed_value = value
536
                 parsed_value = value
519
         except Exception as e:
537
         except Exception as e:
520
-            print(f"Error parsing value: {e}")
538
+            self._exit_error(f"Error parsing value: {e}")
521
-            sys.exit(1)
522
 
539
 
523
         # Set the value
540
         # Set the value
524
         setattr(section_obj, setting_key, parsed_value)
541
         setattr(section_obj, setting_key, parsed_value)
525
 
542
 
526
         # Save settings
543
         # 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
+
528
 
637
 
529
-        print(f"✓ Set {key} = {parsed_value}")
638
+# Return code conventions:
530
-        print(f"Settings saved to {settings._settings_path}")
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:
75
         data = {}
75
         data = {}
76
         for group in groups:
76
         for group in groups:
77
             group_data = {}
77
             group_data = {}
78
-            if group.aliases:
78
+            # Always include the sections, even if empty
79
-                group_data["aliases"] = group.aliases
79
+            group_data["aliases"] = group.aliases if group.aliases else {}
80
-            if group.env_vars:
80
+            group_data["env_vars"] = group.env_vars if group.env_vars else {}
81
-                group_data["env_vars"] = group.env_vars
81
+            group_data["functions"] = group.functions if group.functions else {}
82
-            if group.functions:
82
+            data[group.name] = group_data
83
-                group_data["functions"] = group.functions
84
-            if group_data:
85
-                data[group.name] = group_data
86
 
83
 
87
         with open(config_path, "wb") as f:
84
         with open(config_path, "wb") as f:
88
             tomli_w.dump(data, f)
85
             tomli_w.dump(data, f)
89
 
86
 
90
     except ImportError:
87
     except ImportError:
91
-        # Fallback with proper escaping
88
+        # Enhanced fallback that writes proper nested TOML structure
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
102
         with open(config_path, "w") as f:
89
         with open(config_path, "w") as f:
103
-            # Sort sections for consistent output
90
+            # Write each group
104
-            for section in sorted(data.keys()):
91
+            for group in groups:
105
-                items = data[section]
92
+                # Write main group header
106
-                f.write(f"[{section}]\n")
93
+                f.write(f"[{group.name}]\n")
107
-                # Sort items within section
94
+
108
-                for key in sorted(items.keys()):
95
+                # Write aliases section
109
-                    value = items[key]
96
+                f.write(f"[{group.name}.aliases]\n")
110
-                    # Use proper TOML escaping
97
+                for key in sorted(group.aliases.keys()):
98
+                    value = group.aliases[key]
111
                     escaped_value = escape_toml_value(value)
99
                     escaped_value = escape_toml_value(value)
112
                     f.write(f"{key} = {escaped_value}\n")
100
                     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
114
 
117
 
115
 
118
 
116
 @dataclass
119
 @dataclass
@@ -335,7 +338,7 @@ class Config:
335
                         f"Unknown or invalid section '{section_name}' in group '{group_name}'"
338
                         f"Unknown or invalid section '{section_name}' in group '{group_name}'"
336
                     )
339
                     )
337
 
340
 
338
-            # Create GroupData object if group has any items
341
+            # Create GroupData object (allow empty groups)
339
             new_group = GroupData(
342
             new_group = GroupData(
340
                 name=group_name,
343
                 name=group_name,
341
                 aliases=group_data["aliases"],
344
                 aliases=group_data["aliases"],
@@ -343,14 +346,16 @@ class Config:
343
                 functions=group_data["functions"],
346
                 functions=group_data["functions"],
344
             )
347
             )
345
 
348
 
349
+            # Always add the group, even if empty
350
+            self.groups.append(new_group)
351
+
346
             if new_group.total_items > 0:
352
             if new_group.total_items > 0:
347
-                self.groups.append(new_group)
348
                 logger.debug(
353
                 logger.debug(
349
                     f"Created group '{group_name}' with {len(group_data['aliases'])} aliases, "
354
                     f"Created group '{group_name}' with {len(group_data['aliases'])} aliases, "
350
                     f"{len(group_data['env_vars'])} env_vars, {len(group_data['functions'])} functions"
355
                     f"{len(group_data['env_vars'])} env_vars, {len(group_data['functions'])} functions"
351
                 )
356
                 )
352
             else:
357
             else:
353
-                logger.warning(f"Group '{group_name}' has no items, skipping")
358
+                logger.debug(f"Created empty group '{group_name}'")
354
 
359
 
355
         logger.debug(
360
         logger.debug(
356
             f"Final groups loaded: {[g.name for g in self.groups]} (total: {len(self.groups)})"
361
             f"Final groups loaded: {[g.name for g in self.groups]} (total: {len(self.groups)})"
@@ -358,6 +363,39 @@ class Config:
358
 
363
 
359
     def save(self) -> None:
364
     def save(self) -> None:
360
         """Save the current configuration back to TOML file with secure escaping"""
365
         """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
361
         save_config_securely(self.config_path, self.groups)
399
         save_config_securely(self.config_path, self.groups)
362
 
400
 
363
     def get_group(self, group_name: str) -> Optional[GroupData]:
401
     def get_group(self, group_name: str) -> Optional[GroupData]:
src/shtick/security.pymodified
@@ -1,5 +1,5 @@
1
 """
1
 """
2
-Security validation functions for shtick
2
+Security validation functions for shtick - FIXED FOR GENERATE COMMAND
3
 """
3
 """
4
 
4
 
5
 import os
5
 import os
@@ -58,12 +58,13 @@ def validate_value(value: str, max_length: int = 4096) -> None:
58
         raise ValueError(f"Value too long: maximum {max_length} characters")
58
         raise ValueError(f"Value too long: maximum {max_length} characters")
59
 
59
 
60
 
60
 
61
-def validate_config_path(path: str) -> str:
61
+def validate_config_path(path: str, for_generate: bool = False) -> str:
62
     """
62
     """
63
     Validate and sanitize config path for security.
63
     Validate and sanitize config path for security.
64
 
64
 
65
     Args:
65
     Args:
66
         path: Path to validate
66
         path: Path to validate
67
+        for_generate: If True, use relaxed validation for generate command
67
 
68
 
68
     Returns:
69
     Returns:
69
         Validated absolute path
70
         Validated absolute path
@@ -85,23 +86,46 @@ def validate_config_path(path: str) -> str:
85
         if ".." in path:
86
         if ".." in path:
86
             raise ValueError("Directory traversal detected")
87
             raise ValueError("Directory traversal detected")
87
 
88
 
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
88
         # Block system directories
109
         # Block system directories
89
         if any(
110
         if any(
90
             resolved_str.startswith(forbidden) for forbidden in FORBIDDEN_SYSTEM_PATHS
111
             resolved_str.startswith(forbidden) for forbidden in FORBIDDEN_SYSTEM_PATHS
91
         ):
112
         ):
92
-            raise ValueError(f"Access to system directories is forbidden")
113
+            raise ValueError("Access to system directories is forbidden")
93
 
114
 
94
         # Ensure it's under user's home or current directory
115
         # Ensure it's under user's home or current directory
95
         home = Path.home()
116
         home = Path.home()
96
         cwd = Path.cwd()
117
         cwd = Path.cwd()
97
 
118
 
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
+
98
         if not (resolved.is_relative_to(home) or resolved.is_relative_to(cwd)):
126
         if not (resolved.is_relative_to(home) or resolved.is_relative_to(cwd)):
99
             raise ValueError("Config path must be under home or current directory")
127
             raise ValueError("Config path must be under home or current directory")
100
 
128
 
101
-        # Ensure .toml extension
102
-        if resolved.suffix != ".toml":
103
-            raise ValueError("Config file must have .toml extension")
104
-
105
         return resolved_str
129
         return resolved_str
106
 
130
 
107
     except Exception as e:
131
     except Exception as e:
src/shtick/shtick.pymodified
@@ -592,6 +592,94 @@ class ShtickManager:
592
             logger.error(f"Error generating shell files: {e}")
592
             logger.error(f"Error generating shell files: {e}")
593
             return False
593
             return False
594
 
594
 
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
+
595
     def get_source_command(self, shell: Optional[str] = None) -> Optional[str]:
683
     def get_source_command(self, shell: Optional[str] = None) -> Optional[str]:
596
         """
684
         """
597
         Get the source command for loading shtick in current session.
685
         Get the source command for loading shtick in current session.