tenseleyflow/shtick / 057ca7f

Browse files

refactor stable unthorough

Authored by espadonne
SHA
057ca7f6921a703ea7844dc73c405f9f08b25b17
Parents
9309653
Tree
6c70249

7 changed files

StatusFile+-
A settings.sample.toml 25 0
M src/shtick/cli.py 32 0
M src/shtick/commands.py 138 0
M src/shtick/config.py 58 8
M src/shtick/generator.py 22 2
A src/shtick/settings.py 200 0
M src/shtick/shtick.py 113 7
settings.sample.tomladded
@@ -0,0 +1,25 @@
1
+[generation]
2
+# Shells to generate files for. Empty list = auto-detect based on current shell
3
+shells = ["bash", "dash", "zsh", "nushell", "powershell", "yash", "fish", "oil", "elvish"]
4
+# Enable parallel generation (not implemented yet)
5
+parallel = false
6
+# Consolidate all items into single files per shell
7
+consolidate_files = true
8
+
9
+[behavior]
10
+# Prompt to source changes after modifications
11
+auto_source_prompt = true
12
+# Check for conflicts when adding items
13
+check_conflicts = true
14
+# Create backups before saving config
15
+backup_on_save = false
16
+# Enable interactive prompts
17
+interactive_mode = true
18
+
19
+[performance]
20
+# Cache time-to-live in seconds
21
+cache_ttl = 300
22
+# Enable lazy loading (not implemented yet)
23
+lazy_load = false
24
+# Enable batch operations for better performance
25
+batch_operations = true
src/shtick/cli.pymodified
@@ -124,6 +124,29 @@ def main():
124124
         "--shell", help="Specify shell type (auto-detected if not provided)"
125125
     )
126126
 
127
+    # Settings command
128
+    settings_parser = subparsers.add_parser("settings", help="Manage shtick settings")
129
+    settings_subparsers = settings_parser.add_subparsers(
130
+        dest="settings_command", help="Settings commands"
131
+    )
132
+
133
+    # Settings subcommands
134
+    settings_init_parser = settings_subparsers.add_parser(
135
+        "init", help="Create default settings file"
136
+    )
137
+
138
+    settings_show_parser = settings_subparsers.add_parser(
139
+        "show", help="Show current settings"
140
+    )
141
+
142
+    settings_set_parser = settings_subparsers.add_parser(
143
+        "set", help="Set a setting value"
144
+    )
145
+    settings_set_parser.add_argument(
146
+        "key", help="Setting key (e.g., generation.shells)"
147
+    )
148
+    settings_set_parser.add_argument("value", help="Setting value")
149
+
127150
     args = parser.parse_args()
128151
 
129152
     # Set up logging first
@@ -172,6 +195,15 @@ def main():
172195
             display.shells(args.long)
173196
         elif args.command == "source":
174197
             commands.source_command(args.shell)
198
+        elif args.command == "settings":
199
+            if args.settings_command == "init":
200
+                commands.settings_init()
201
+            elif args.settings_command == "show":
202
+                commands.settings_show()
203
+            elif args.settings_command == "set":
204
+                commands.settings_set(args.key, args.value)
205
+            else:
206
+                settings_parser.print_help()
175207
 
176208
     except KeyboardInterrupt:
177209
         logger.debug("Operation cancelled by user")
src/shtick/commands.pymodified
@@ -54,6 +54,13 @@ class ShtickCommands:
5454
 
5555
     def offer_auto_source(self):
5656
         """Offer to source shtick in current shell session"""
57
+        # Check settings first
58
+        from shtick.settings import Settings
59
+
60
+        settings = Settings()
61
+        if not settings.behavior.auto_source_prompt:
62
+            return
63
+
5764
         current_shell = self.get_current_shell()
5865
         if not current_shell or current_shell not in ["bash", "zsh", "fish"]:
5966
             return
@@ -403,3 +410,134 @@ class ShtickCommands:
403410
 
404411
         # Output the source command that can be eval'd
405412
         print(f"source {loader_path}")
413
+
414
+    # Settings commands
415
+    def settings_init(self):
416
+        """Initialize settings file with defaults"""
417
+        from shtick.settings import Settings
418
+
419
+        settings = Settings()
420
+
421
+        if os.path.exists(settings._settings_path):
422
+            try:
423
+                response = (
424
+                    input("Settings file already exists. Overwrite? [y/N]: ")
425
+                    .strip()
426
+                    .lower()
427
+                )
428
+                if response not in ["y", "yes"]:
429
+                    print("Cancelled")
430
+                    return
431
+            except (KeyboardInterrupt, EOFError):
432
+                print("\nCancelled")
433
+                return
434
+
435
+        settings.create_default_settings_file()
436
+        print(f"✓ Created settings file at {settings._settings_path}")
437
+        print("\nYou can now customize your shtick behavior by editing this file.")
438
+
439
+    def settings_show(self):
440
+        """Show current settings"""
441
+        from shtick.settings import Settings
442
+
443
+        settings = Settings()
444
+
445
+        print("Shtick Settings")
446
+        print("=" * 50)
447
+
448
+        print("\n[generation]")
449
+        print(f"  shells = {settings.generation.shells or '[] (auto-detect)'}")
450
+        print(f"  parallel = {settings.generation.parallel}")
451
+        print(f"  consolidate_files = {settings.generation.consolidate_files}")
452
+
453
+        print("\n[behavior]")
454
+        print(f"  auto_source_prompt = {settings.behavior.auto_source_prompt}")
455
+        print(f"  check_conflicts = {settings.behavior.check_conflicts}")
456
+        print(f"  backup_on_save = {settings.behavior.backup_on_save}")
457
+        print(f"  interactive_mode = {settings.behavior.interactive_mode}")
458
+
459
+        print("\n[performance]")
460
+        print(f"  cache_ttl = {settings.performance.cache_ttl}")
461
+        print(f"  lazy_load = {settings.performance.lazy_load}")
462
+        print(f"  batch_operations = {settings.performance.batch_operations}")
463
+
464
+        print(f"\nSettings file: {settings._settings_path}")
465
+        if not os.path.exists(settings._settings_path):
466
+            print("(No settings file found - using defaults)")
467
+            print("Run 'shtick settings init' to create one")
468
+
469
+    def settings_set(self, key: str, value: str):
470
+        """Set a specific setting value"""
471
+        from shtick.settings import Settings
472
+
473
+        settings = Settings()
474
+
475
+        # Parse the key (e.g., "generation.shells")
476
+        parts = key.split(".")
477
+        if len(parts) != 2:
478
+            print(
479
+                f"Error: Invalid key format. Use 'section.key' (e.g., 'generation.shells')"
480
+            )
481
+            sys.exit(1)
482
+
483
+        section, setting_key = parts
484
+
485
+        # Validate section
486
+        if section not in ["generation", "behavior", "performance"]:
487
+            print(
488
+                f"Error: Invalid section '{section}'. Must be one of: generation, behavior, performance"
489
+            )
490
+            sys.exit(1)
491
+
492
+        # Get the section object
493
+        section_obj = getattr(settings, section)
494
+
495
+        # Check if key exists
496
+        if not hasattr(section_obj, setting_key):
497
+            print(f"Error: Invalid key '{setting_key}' for section '{section}'")
498
+            print(f"Valid keys: {', '.join(vars(section_obj).keys())}")
499
+            sys.exit(1)
500
+
501
+        # Parse the value based on type
502
+        current_value = getattr(section_obj, setting_key)
503
+        try:
504
+            if isinstance(current_value, bool):
505
+                # Parse boolean
506
+                if value.lower() in ["true", "1", "yes", "on"]:
507
+                    parsed_value = True
508
+                elif value.lower() in ["false", "0", "no", "off"]:
509
+                    parsed_value = False
510
+                else:
511
+                    raise ValueError(f"Invalid boolean value: {value}")
512
+            elif isinstance(current_value, int):
513
+                # Parse integer
514
+                parsed_value = int(value)
515
+            elif isinstance(current_value, list):
516
+                # Parse list (simple eval for now - could be improved)
517
+                if value == "[]":
518
+                    parsed_value = []
519
+                elif value.startswith("[") and value.endswith("]"):
520
+                    # Simple parsing - just split by comma
521
+                    parsed_value = [
522
+                        s.strip().strip("\"'")
523
+                        for s in value[1:-1].split(",")
524
+                        if s.strip()
525
+                    ]
526
+                else:
527
+                    # Single value becomes a list
528
+                    parsed_value = [value]
529
+            else:
530
+                # String value
531
+                parsed_value = value
532
+        except Exception as e:
533
+            print(f"Error parsing value: {e}")
534
+            sys.exit(1)
535
+
536
+        # Set the value
537
+        setattr(section_obj, setting_key, parsed_value)
538
+
539
+        # Save settings
540
+        settings.save()
541
+
542
+        print(f"✓ Set {key} = {parsed_value}")
543
+        print(f"Settings saved to {settings._settings_path}")
src/shtick/config.pymodified
@@ -61,8 +61,11 @@ class GroupData:
6161
 class Config:
6262
     """Main configuration handler"""
6363
 
64
-    # Class variable for shell detection caching
64
+    # Class variables for caching
6565
     _detected_shell = None
66
+    _active_groups_cache = None
67
+    _active_groups_mtime = None
68
+    _active_groups_file_path = None
6669
 
6770
     def __init__(self, config_path: Optional[str] = None):
6871
         self.config_path = config_path or self.get_default_config_path()
@@ -96,14 +99,44 @@ class Config:
9699
         """Clear the cached shell detection (useful for testing)"""
97100
         cls._detected_shell = None
98101
 
102
+    @classmethod
103
+    def clear_all_caches(cls):
104
+        """Clear all caches (useful for testing or forced refresh)"""
105
+        cls._detected_shell = None
106
+        cls._active_groups_cache = None
107
+        cls._active_groups_mtime = None
108
+        cls._active_groups_file_path = None
109
+
99110
     def load_active_groups(self) -> List[str]:
100
-        """Load list of currently active groups"""
111
+        """Load list of currently active groups with caching"""
101112
         active_file = self.get_active_groups_file()
102
-        if not os.path.exists(active_file):
103
-            return []
104113
 
105
-        with open(active_file, "r") as f:
106
-            return [line.strip() for line in f if line.strip()]
114
+        # Check if we need to reload from disk
115
+        if os.path.exists(active_file):
116
+            current_mtime = os.path.getmtime(active_file)
117
+            if (
118
+                self._active_groups_cache is None
119
+                or self._active_groups_file_path != active_file
120
+                or self._active_groups_mtime != current_mtime
121
+            ):
122
+
123
+                logger.debug(f"Reloading active groups from {active_file}")
124
+                with open(active_file, "r") as f:
125
+                    self._active_groups_cache = [
126
+                        line.strip() for line in f if line.strip()
127
+                    ]
128
+                self._active_groups_mtime = current_mtime
129
+                self._active_groups_file_path = active_file
130
+            else:
131
+                logger.debug("Using cached active groups")
132
+        else:
133
+            # File doesn't exist, return empty list
134
+            self._active_groups_cache = []
135
+            self._active_groups_mtime = None
136
+
137
+        return (
138
+            self._active_groups_cache.copy()
139
+        )  # Return a copy to prevent external modification
107140
 
108141
     def save_active_groups(self, active_groups: List[str]) -> None:
109142
         """Save list of active groups to state file"""
@@ -114,6 +147,11 @@ class Config:
114147
             for group in active_groups:
115148
                 f.write(f"{group}\n")
116149
 
150
+        # Invalidate cache by updating mtime
151
+        self._active_groups_cache = active_groups.copy()
152
+        self._active_groups_mtime = os.path.getmtime(active_file)
153
+        self._active_groups_file_path = active_file
154
+
117155
     def activate_group(self, group_name: str) -> bool:
118156
         """Activate a group. Returns True if successful."""
119157
         # Check if group exists
@@ -284,10 +322,19 @@ class Config:
284322
 
285323
     def get_all_shells_to_generate(self) -> List[str]:
286324
         """Get list of shells to generate files for based on user settings"""
287
-        # For now, just detect current shell and common ones
288
-        # Later this can read from settings.toml
325
+        from .settings import Settings
326
+
327
+        settings = Settings()
328
+
329
+        # If shells are explicitly set in settings, use those
330
+        if settings.generation.shells:
331
+            logger.debug(f"Using shells from settings: {settings.generation.shells}")
332
+            return settings.generation.shells
333
+
334
+        # Otherwise auto-detect based on current shell
289335
         current_shell = self.get_current_shell()
290336
         if not current_shell:
337
+            logger.debug("No current shell detected, using defaults")
291338
             return ["bash", "zsh", "fish"]  # Common defaults
292339
 
293340
         # Include current shell and close relatives
@@ -300,4 +347,7 @@ class Config:
300347
         }
301348
 
302349
         shells = shell_families.get(current_shell, [current_shell])
350
+        logger.debug(
351
+            f"Auto-detected shells based on current shell '{current_shell}': {shells}"
352
+        )
303353
         return list(set(shells))  # Remove duplicates
src/shtick/generator.pymodified
@@ -19,16 +19,30 @@ class Generator:
1919
         self.output_base_dir = output_base_dir or Config.get_output_dir()
2020
         # Get shells to generate for once
2121
         self._shells_to_generate = None
22
+        self._config_for_shells = None
2223
 
2324
     @property
2425
     def shells_to_generate(self) -> List[str]:
2526
         """Get list of shells to generate files for"""
2627
         if self._shells_to_generate is None:
27
-            config = Config()
28
-            self._shells_to_generate = config.get_all_shells_to_generate()
28
+            # If we have a config instance stored, use it
29
+            if self._config_for_shells:
30
+                self._shells_to_generate = (
31
+                    self._config_for_shells.get_all_shells_to_generate()
32
+                )
33
+            else:
34
+                # Otherwise create a temporary one just for getting shells
35
+                config = Config()
36
+                self._shells_to_generate = config.get_all_shells_to_generate()
2937
             logger.debug(f"Will generate files for shells: {self._shells_to_generate}")
3038
         return self._shells_to_generate
3139
 
40
+    def set_config_for_shells(self, config: Config) -> None:
41
+        """Set config instance to use for shell detection"""
42
+        self._config_for_shells = config
43
+        # Clear cached shells to force re-read
44
+        self._shells_to_generate = None
45
+
3246
     def ensure_output_dir(self, group_name: str) -> str:
3347
         """Ensure output directory exists and return the path"""
3448
         output_dir = os.path.join(self.output_base_dir, group_name)
@@ -118,6 +132,9 @@ class Generator:
118132
             print("No groups found in configuration")
119133
             return
120134
 
135
+        # Set config for shell detection FIRST
136
+        self.set_config_for_shells(config)
137
+
121138
         print(f"Generating shell files for {len(config.groups)} groups...")
122139
         print(f"Target shells: {', '.join(self.shells_to_generate)}")
123140
 
@@ -136,6 +153,9 @@ class Generator:
136153
         """Generate dynamic loader files that source persistent + active groups"""
137154
         logger.info("Generating dynamic loader files...")
138155
 
156
+        # Set config for shell detection
157
+        self.set_config_for_shells(config)
158
+
139159
         active_groups = config.load_active_groups()
140160
         persistent_group = config.get_persistent_group()
141161
 
src/shtick/settings.pyadded
@@ -0,0 +1,200 @@
1
+"""
2
+Settings management for shtick
3
+"""
4
+
5
+import os
6
+import tomllib
7
+import logging
8
+from pathlib import Path
9
+from typing import Dict, List, Optional, Any
10
+from dataclasses import dataclass, field
11
+
12
+logger = logging.getLogger("shtick")
13
+
14
+
15
+@dataclass
16
+class GenerationSettings:
17
+    """Settings for file generation"""
18
+
19
+    shells: List[str] = field(default_factory=list)  # Empty = auto-detect
20
+    parallel: bool = False  # Threading not implemented yet
21
+    consolidate_files: bool = True
22
+
23
+
24
+@dataclass
25
+class BehaviorSettings:
26
+    """Settings for shtick behavior"""
27
+
28
+    auto_source_prompt: bool = True
29
+    check_conflicts: bool = True
30
+    backup_on_save: bool = False
31
+    interactive_mode: bool = True
32
+
33
+
34
+@dataclass
35
+class PerformanceSettings:
36
+    """Settings for performance optimization"""
37
+
38
+    cache_ttl: int = 300  # 5 minutes
39
+    lazy_load: bool = False  # Not implemented yet
40
+    batch_operations: bool = True
41
+
42
+
43
+class Settings:
44
+    """Manages shtick settings and preferences"""
45
+
46
+    # Singleton instance
47
+    _instance = None
48
+    _loaded = False
49
+
50
+    def __new__(cls):
51
+        if cls._instance is None:
52
+            cls._instance = super().__new__(cls)
53
+        return cls._instance
54
+
55
+    def __init__(self):
56
+        if not self._loaded:
57
+            self.generation = GenerationSettings()
58
+            self.behavior = BehaviorSettings()
59
+            self.performance = PerformanceSettings()
60
+            self._settings_path = self.get_settings_path()
61
+            self._load()
62
+            Settings._loaded = True
63
+
64
+    @staticmethod
65
+    def get_settings_path() -> str:
66
+        """Get the settings file path"""
67
+        return os.path.expanduser("~/.config/shtick/settings.toml")
68
+
69
+    def _load(self) -> None:
70
+        """Load settings from file if it exists"""
71
+        if not os.path.exists(self._settings_path):
72
+            logger.debug("No settings file found, using defaults")
73
+            return
74
+
75
+        try:
76
+            logger.debug(f"Loading settings from {self._settings_path}")
77
+            with open(self._settings_path, "rb") as f:
78
+                data = tomllib.load(f)
79
+
80
+            # Load generation settings
81
+            if "generation" in data:
82
+                gen_data = data["generation"]
83
+                self.generation.shells = gen_data.get("shells", [])
84
+                self.generation.parallel = gen_data.get("parallel", False)
85
+                self.generation.consolidate_files = gen_data.get(
86
+                    "consolidate_files", True
87
+                )
88
+
89
+            # Load behavior settings
90
+            if "behavior" in data:
91
+                beh_data = data["behavior"]
92
+                self.behavior.auto_source_prompt = beh_data.get(
93
+                    "auto_source_prompt", True
94
+                )
95
+                self.behavior.check_conflicts = beh_data.get("check_conflicts", True)
96
+                self.behavior.backup_on_save = beh_data.get("backup_on_save", False)
97
+                self.behavior.interactive_mode = beh_data.get("interactive_mode", True)
98
+
99
+            # Load performance settings
100
+            if "performance" in data:
101
+                perf_data = data["performance"]
102
+                self.performance.cache_ttl = perf_data.get("cache_ttl", 300)
103
+                self.performance.lazy_load = perf_data.get("lazy_load", False)
104
+                self.performance.batch_operations = perf_data.get(
105
+                    "batch_operations", True
106
+                )
107
+
108
+            logger.debug("Settings loaded successfully")
109
+
110
+        except Exception as e:
111
+            logger.warning(f"Failed to load settings: {e}, using defaults")
112
+
113
+    def save(self) -> None:
114
+        """Save current settings to file"""
115
+        # Ensure directory exists
116
+        os.makedirs(os.path.dirname(self._settings_path), exist_ok=True)
117
+
118
+        # Build settings dictionary
119
+        settings_dict = {
120
+            "generation": {
121
+                "shells": self.generation.shells,
122
+                "parallel": self.generation.parallel,
123
+                "consolidate_files": self.generation.consolidate_files,
124
+            },
125
+            "behavior": {
126
+                "auto_source_prompt": self.behavior.auto_source_prompt,
127
+                "check_conflicts": self.behavior.check_conflicts,
128
+                "backup_on_save": self.behavior.backup_on_save,
129
+                "interactive_mode": self.behavior.interactive_mode,
130
+            },
131
+            "performance": {
132
+                "cache_ttl": self.performance.cache_ttl,
133
+                "lazy_load": self.performance.lazy_load,
134
+                "batch_operations": self.performance.batch_operations,
135
+            },
136
+        }
137
+
138
+        # Write TOML file
139
+        with open(self._settings_path, "w") as f:
140
+            f.write("# Shtick settings file\n")
141
+            f.write("# Generated automatically - edit as needed\n\n")
142
+
143
+            for section, values in settings_dict.items():
144
+                f.write(f"[{section}]\n")
145
+                for key, value in values.items():
146
+                    if isinstance(value, bool):
147
+                        f.write(f"{key} = {str(value).lower()}\n")
148
+                    elif isinstance(value, list):
149
+                        if value:  # Non-empty list
150
+                            f.write(f"{key} = {repr(value)}\n")
151
+                        else:
152
+                            f.write(f"{key} = []  # Empty = auto-detect\n")
153
+                    else:
154
+                        f.write(f"{key} = {value}\n")
155
+                f.write("\n")
156
+
157
+    def create_default_settings_file(self) -> None:
158
+        """Create a default settings file with comments"""
159
+        os.makedirs(os.path.dirname(self._settings_path), exist_ok=True)
160
+
161
+        content = """# Shtick settings file
162
+# This file controls various shtick behaviors and optimizations
163
+
164
+[generation]
165
+# Shells to generate files for. Empty list = auto-detect based on current shell
166
+shells = []
167
+# Enable parallel generation (not implemented yet)
168
+parallel = false
169
+# Consolidate all items into single files per shell
170
+consolidate_files = true
171
+
172
+[behavior]
173
+# Prompt to source changes after modifications
174
+auto_source_prompt = true
175
+# Check for conflicts when adding items
176
+check_conflicts = true
177
+# Create backups before saving config
178
+backup_on_save = false
179
+# Enable interactive prompts
180
+interactive_mode = true
181
+
182
+[performance]
183
+# Cache time-to-live in seconds
184
+cache_ttl = 300
185
+# Enable lazy loading (not implemented yet)
186
+lazy_load = false
187
+# Enable batch operations for better performance
188
+batch_operations = true
189
+"""
190
+
191
+        with open(self._settings_path, "w") as f:
192
+            f.write(content)
193
+
194
+        logger.info(f"Created default settings file at {self._settings_path}")
195
+
196
+    @classmethod
197
+    def reset(cls):
198
+        """Reset the singleton instance (useful for testing)"""
199
+        cls._instance = None
200
+        cls._loaded = False
src/shtick/shtick.pymodified
@@ -40,14 +40,12 @@ class ShtickManager:
4040
             debug: Enable debug output
4141
         """
4242
         self.config_path = config_path or Config.get_default_config_path()
43
+        self.debug = debug
4344
 
44
-        # Set up logging
45
-        if debug:
46
-            logging.basicConfig(
47
-                level=logging.DEBUG, format="%(name)s - %(levelname)s - %(message)s"
48
-            )
49
-        else:
50
-            logging.basicConfig(level=logging.INFO, format="%(message)s")
45
+        # Set up logging using centralized setup
46
+        from .logger import setup_logging
47
+
48
+        self.logger = setup_logging(debug=debug)
5149
 
5250
         self._config = None
5351
         self._generator = Generator()
@@ -269,6 +267,13 @@ class ShtickManager:
269267
         try:
270268
             config = self._get_config()
271269
 
270
+            # Use settings if check_conflicts not explicitly set
271
+            if check_conflicts is None:
272
+                from .settings import Settings
273
+
274
+                settings = Settings()
275
+                check_conflicts = settings.behavior.check_conflicts
276
+
272277
             # Check for conflicts if requested
273278
             if check_conflicts:
274279
                 conflicts = self.check_conflicts(item_type, key, group_name)
@@ -312,6 +317,107 @@ class ShtickManager:
312317
             logger.error(f"Error removing {item_type}: {e}")
313318
             return False
314319
 
320
+    # Batch operations
321
+    def add_items_batch(
322
+        self, items: List[Dict[str, Union[str, bool]]]
323
+    ) -> Dict[str, List[str]]:
324
+        """
325
+        Add multiple items in one batch operation.
326
+
327
+        Args:
328
+            items: List of dicts with keys: type, group, key, value, check_conflicts (optional)
329
+                Example: [
330
+                    {'type': 'alias', 'group': 'dev', 'key': 'll', 'value': 'ls -la'},
331
+                    {'type': 'env', 'group': 'dev', 'key': 'DEBUG', 'value': '1'},
332
+                ]
333
+
334
+        Returns:
335
+            Dictionary with 'success' and 'failed' lists of item keys
336
+        """
337
+        results = {"success": [], "failed": []}
338
+        config = self._get_config()
339
+        affected_groups = set()
340
+
341
+        for item in items:
342
+            try:
343
+                item_type = item["type"]
344
+                group_name = item["group"]
345
+                key = item["key"]
346
+                value = item["value"]
347
+                check_conflicts = item.get("check_conflicts", True)
348
+
349
+                # Check for conflicts if requested
350
+                if check_conflicts:
351
+                    conflicts = self.check_conflicts(item_type, key, group_name)
352
+                    if conflicts:
353
+                        logger.warning(
354
+                            f"Item '{key}' exists in groups: {[c[0] for c in conflicts]}"
355
+                        )
356
+
357
+                # Add the item
358
+                config.add_item(item_type, group_name, key, value)
359
+                results["success"].append(key)
360
+
361
+                # Track affected groups
362
+                if group_name == "persistent" or config.is_group_active(group_name):
363
+                    affected_groups.add(group_name)
364
+
365
+            except Exception as e:
366
+                logger.error(f"Failed to add item '{item.get('key', 'unknown')}': {e}")
367
+                results["failed"].append(item.get("key", "unknown"))
368
+
369
+        # Save and regenerate once for all affected groups
370
+        if results["success"]:
371
+            self._save_and_regenerate(list(affected_groups))
372
+
373
+        return results
374
+
375
+    def remove_items_batch(self, items: List[Dict[str, str]]) -> Dict[str, List[str]]:
376
+        """
377
+        Remove multiple items in one batch operation.
378
+
379
+        Args:
380
+            items: List of dicts with keys: type, group, key
381
+                Example: [
382
+                    {'type': 'alias', 'group': 'dev', 'key': 'll'},
383
+                    {'type': 'env', 'group': 'dev', 'key': 'DEBUG'},
384
+                ]
385
+
386
+        Returns:
387
+            Dictionary with 'success' and 'failed' lists of item keys
388
+        """
389
+        results = {"success": [], "failed": []}
390
+        config = self._get_config()
391
+        affected_groups = set()
392
+
393
+        for item in items:
394
+            try:
395
+                item_type = item["type"]
396
+                group_name = item["group"]
397
+                key = item["key"]
398
+
399
+                # Remove the item
400
+                if config.remove_item(item_type, group_name, key):
401
+                    results["success"].append(key)
402
+
403
+                    # Track affected groups
404
+                    if group_name == "persistent" or config.is_group_active(group_name):
405
+                        affected_groups.add(group_name)
406
+                else:
407
+                    results["failed"].append(key)
408
+
409
+            except Exception as e:
410
+                logger.error(
411
+                    f"Failed to remove item '{item.get('key', 'unknown')}': {e}"
412
+                )
413
+                results["failed"].append(item.get("key", "unknown"))
414
+
415
+        # Save and regenerate once for all affected groups
416
+        if results["success"]:
417
+            self._save_and_regenerate(list(affected_groups))
418
+
419
+        return results
420
+
315421
     # Group management
316422
     def activate_group(self, group: str) -> bool:
317423
         """