refactor stable unthorough
- SHA
057ca7f6921a703ea7844dc73c405f9f08b25b17- Parents
-
9309653 - Tree
6c70249
057ca7f
057ca7f6921a703ea7844dc73c405f9f08b25b179309653
6c70249| Status | File | + | - |
|---|---|---|---|
| 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(): | |||
| 124 | "--shell", help="Specify shell type (auto-detected if not provided)" | 124 | "--shell", help="Specify shell type (auto-detected if not provided)" |
| 125 | ) | 125 | ) |
| 126 | 126 | ||
| 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 | + | ||
| 127 | args = parser.parse_args() | 150 | args = parser.parse_args() |
| 128 | 151 | ||
| 129 | # Set up logging first | 152 | # Set up logging first |
@@ -172,6 +195,15 @@ def main(): | |||
| 172 | display.shells(args.long) | 195 | display.shells(args.long) |
| 173 | elif args.command == "source": | 196 | elif args.command == "source": |
| 174 | commands.source_command(args.shell) | 197 | 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() | ||
| 175 | 207 | ||
| 176 | except KeyboardInterrupt: | 208 | except KeyboardInterrupt: |
| 177 | logger.debug("Operation cancelled by user") | 209 | logger.debug("Operation cancelled by user") |
src/shtick/commands.pymodified@@ -54,6 +54,13 @@ class ShtickCommands: | |||
| 54 | 54 | ||
| 55 | def offer_auto_source(self): | 55 | def offer_auto_source(self): |
| 56 | """Offer to source shtick in current shell session""" | 56 | """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 | + | ||
| 57 | current_shell = self.get_current_shell() | 64 | current_shell = self.get_current_shell() |
| 58 | if not current_shell or current_shell not in ["bash", "zsh", "fish"]: | 65 | if not current_shell or current_shell not in ["bash", "zsh", "fish"]: |
| 59 | return | 66 | return |
@@ -403,3 +410,134 @@ class ShtickCommands: | |||
| 403 | 410 | ||
| 404 | # Output the source command that can be eval'd | 411 | # Output the source command that can be eval'd |
| 405 | print(f"source {loader_path}") | 412 | 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: | |||
| 61 | class Config: | 61 | class Config: |
| 62 | """Main configuration handler""" | 62 | """Main configuration handler""" |
| 63 | 63 | ||
| 64 | - # Class variable for shell detection caching | 64 | + # Class variables for caching |
| 65 | _detected_shell = None | 65 | _detected_shell = None |
| 66 | + _active_groups_cache = None | ||
| 67 | + _active_groups_mtime = None | ||
| 68 | + _active_groups_file_path = None | ||
| 66 | 69 | ||
| 67 | def __init__(self, config_path: Optional[str] = None): | 70 | def __init__(self, config_path: Optional[str] = None): |
| 68 | self.config_path = config_path or self.get_default_config_path() | 71 | self.config_path = config_path or self.get_default_config_path() |
@@ -96,14 +99,44 @@ class Config: | |||
| 96 | """Clear the cached shell detection (useful for testing)""" | 99 | """Clear the cached shell detection (useful for testing)""" |
| 97 | cls._detected_shell = None | 100 | cls._detected_shell = None |
| 98 | 101 | ||
| 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 | + | ||
| 99 | def load_active_groups(self) -> List[str]: | 110 | def load_active_groups(self) -> List[str]: |
| 100 | - """Load list of currently active groups""" | 111 | + """Load list of currently active groups with caching""" |
| 101 | active_file = self.get_active_groups_file() | 112 | active_file = self.get_active_groups_file() |
| 102 | - if not os.path.exists(active_file): | ||
| 103 | - return [] | ||
| 104 | 113 | ||
| 105 | - with open(active_file, "r") as f: | 114 | + # Check if we need to reload from disk |
| 106 | - return [line.strip() for line in f if line.strip()] | 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 | ||
| 107 | 140 | ||
| 108 | def save_active_groups(self, active_groups: List[str]) -> None: | 141 | def save_active_groups(self, active_groups: List[str]) -> None: |
| 109 | """Save list of active groups to state file""" | 142 | """Save list of active groups to state file""" |
@@ -114,6 +147,11 @@ class Config: | |||
| 114 | for group in active_groups: | 147 | for group in active_groups: |
| 115 | f.write(f"{group}\n") | 148 | f.write(f"{group}\n") |
| 116 | 149 | ||
| 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 | + | ||
| 117 | def activate_group(self, group_name: str) -> bool: | 155 | def activate_group(self, group_name: str) -> bool: |
| 118 | """Activate a group. Returns True if successful.""" | 156 | """Activate a group. Returns True if successful.""" |
| 119 | # Check if group exists | 157 | # Check if group exists |
@@ -284,10 +322,19 @@ class Config: | |||
| 284 | 322 | ||
| 285 | def get_all_shells_to_generate(self) -> List[str]: | 323 | def get_all_shells_to_generate(self) -> List[str]: |
| 286 | """Get list of shells to generate files for based on user settings""" | 324 | """Get list of shells to generate files for based on user settings""" |
| 287 | - # For now, just detect current shell and common ones | 325 | + from .settings import Settings |
| 288 | - # Later this can read from settings.toml | 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 | ||
| 289 | current_shell = self.get_current_shell() | 335 | current_shell = self.get_current_shell() |
| 290 | if not current_shell: | 336 | if not current_shell: |
| 337 | + logger.debug("No current shell detected, using defaults") | ||
| 291 | return ["bash", "zsh", "fish"] # Common defaults | 338 | return ["bash", "zsh", "fish"] # Common defaults |
| 292 | 339 | ||
| 293 | # Include current shell and close relatives | 340 | # Include current shell and close relatives |
@@ -300,4 +347,7 @@ class Config: | |||
| 300 | } | 347 | } |
| 301 | 348 | ||
| 302 | shells = shell_families.get(current_shell, [current_shell]) | 349 | shells = shell_families.get(current_shell, [current_shell]) |
| 350 | + logger.debug( | ||
| 351 | + f"Auto-detected shells based on current shell '{current_shell}': {shells}" | ||
| 352 | + ) | ||
| 303 | return list(set(shells)) # Remove duplicates | 353 | return list(set(shells)) # Remove duplicates |
src/shtick/generator.pymodified@@ -19,16 +19,30 @@ class Generator: | |||
| 19 | self.output_base_dir = output_base_dir or Config.get_output_dir() | 19 | self.output_base_dir = output_base_dir or Config.get_output_dir() |
| 20 | # Get shells to generate for once | 20 | # Get shells to generate for once |
| 21 | self._shells_to_generate = None | 21 | self._shells_to_generate = None |
| 22 | + self._config_for_shells = None | ||
| 22 | 23 | ||
| 23 | @property | 24 | @property |
| 24 | def shells_to_generate(self) -> List[str]: | 25 | def shells_to_generate(self) -> List[str]: |
| 25 | """Get list of shells to generate files for""" | 26 | """Get list of shells to generate files for""" |
| 26 | if self._shells_to_generate is None: | 27 | if self._shells_to_generate is None: |
| 27 | - config = Config() | 28 | + # If we have a config instance stored, use it |
| 28 | - self._shells_to_generate = config.get_all_shells_to_generate() | 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() | ||
| 29 | logger.debug(f"Will generate files for shells: {self._shells_to_generate}") | 37 | logger.debug(f"Will generate files for shells: {self._shells_to_generate}") |
| 30 | return self._shells_to_generate | 38 | return self._shells_to_generate |
| 31 | 39 | ||
| 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 | + | ||
| 32 | def ensure_output_dir(self, group_name: str) -> str: | 46 | def ensure_output_dir(self, group_name: str) -> str: |
| 33 | """Ensure output directory exists and return the path""" | 47 | """Ensure output directory exists and return the path""" |
| 34 | output_dir = os.path.join(self.output_base_dir, group_name) | 48 | output_dir = os.path.join(self.output_base_dir, group_name) |
@@ -118,6 +132,9 @@ class Generator: | |||
| 118 | print("No groups found in configuration") | 132 | print("No groups found in configuration") |
| 119 | return | 133 | return |
| 120 | 134 | ||
| 135 | + # Set config for shell detection FIRST | ||
| 136 | + self.set_config_for_shells(config) | ||
| 137 | + | ||
| 121 | print(f"Generating shell files for {len(config.groups)} groups...") | 138 | print(f"Generating shell files for {len(config.groups)} groups...") |
| 122 | print(f"Target shells: {', '.join(self.shells_to_generate)}") | 139 | print(f"Target shells: {', '.join(self.shells_to_generate)}") |
| 123 | 140 | ||
@@ -136,6 +153,9 @@ class Generator: | |||
| 136 | """Generate dynamic loader files that source persistent + active groups""" | 153 | """Generate dynamic loader files that source persistent + active groups""" |
| 137 | logger.info("Generating dynamic loader files...") | 154 | logger.info("Generating dynamic loader files...") |
| 138 | 155 | ||
| 156 | + # Set config for shell detection | ||
| 157 | + self.set_config_for_shells(config) | ||
| 158 | + | ||
| 139 | active_groups = config.load_active_groups() | 159 | active_groups = config.load_active_groups() |
| 140 | persistent_group = config.get_persistent_group() | 160 | persistent_group = config.get_persistent_group() |
| 141 | 161 | ||
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: | |||
| 40 | debug: Enable debug output | 40 | debug: Enable debug output |
| 41 | """ | 41 | """ |
| 42 | self.config_path = config_path or Config.get_default_config_path() | 42 | self.config_path = config_path or Config.get_default_config_path() |
| 43 | + self.debug = debug | ||
| 43 | 44 | ||
| 44 | - # Set up logging | 45 | + # Set up logging using centralized setup |
| 45 | - if debug: | 46 | + from .logger import setup_logging |
| 46 | - logging.basicConfig( | 47 | + |
| 47 | - level=logging.DEBUG, format="%(name)s - %(levelname)s - %(message)s" | 48 | + self.logger = setup_logging(debug=debug) |
| 48 | - ) | ||
| 49 | - else: | ||
| 50 | - logging.basicConfig(level=logging.INFO, format="%(message)s") | ||
| 51 | 49 | ||
| 52 | self._config = None | 50 | self._config = None |
| 53 | self._generator = Generator() | 51 | self._generator = Generator() |
@@ -269,6 +267,13 @@ class ShtickManager: | |||
| 269 | try: | 267 | try: |
| 270 | config = self._get_config() | 268 | config = self._get_config() |
| 271 | 269 | ||
| 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 | + | ||
| 272 | # Check for conflicts if requested | 277 | # Check for conflicts if requested |
| 273 | if check_conflicts: | 278 | if check_conflicts: |
| 274 | conflicts = self.check_conflicts(item_type, key, group_name) | 279 | conflicts = self.check_conflicts(item_type, key, group_name) |
@@ -312,6 +317,107 @@ class ShtickManager: | |||
| 312 | logger.error(f"Error removing {item_type}: {e}") | 317 | logger.error(f"Error removing {item_type}: {e}") |
| 313 | return False | 318 | return False |
| 314 | 319 | ||
| 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 | + | ||
| 315 | # Group management | 421 | # Group management |
| 316 | def activate_group(self, group: str) -> bool: | 422 | def activate_group(self, group: str) -> bool: |
| 317 | """ | 423 | """ |