refactor
- SHA
63edf82430248db97f82097f39ccf18e7a5b75e6- Parents
-
fb3e924 - Tree
c7cf71f
63edf82
63edf82430248db97f82097f39ccf18e7a5b75e6fb3e924
c7cf71f| Status | File | + | - |
|---|---|---|---|
| M |
src/shtick/cli.py
|
56 | 597 |
| A |
src/shtick/commands.py
|
543 | 0 |
| A |
src/shtick/display.py
|
283 | 0 |
src/shtick/cli.pymodified@@ -6,593 +6,8 @@ Generates shell configuration files from TOML config | ||
| 6 | 6 | |
| 7 | 7 | import sys |
| 8 | 8 | import argparse |
| 9 | -import os | |
| 10 | -from typing import Optional | |
| 11 | - | |
| 12 | -# Package imports | |
| 13 | -from shtick.config import Config | |
| 14 | -from shtick.generator import Generator | |
| 15 | -from shtick.shells import get_supported_shells | |
| 16 | - | |
| 17 | - | |
| 18 | -def cmd_generate(args) -> None: | |
| 19 | - """Generate shell files from config""" | |
| 20 | - config_path = args.config or Config.get_default_config_path() | |
| 21 | - | |
| 22 | - try: | |
| 23 | - config = Config(config_path, debug=args.debug) | |
| 24 | - config.load() | |
| 25 | - | |
| 26 | - generator = Generator() | |
| 27 | - generator.generate_all(config, interactive=not args.terse) | |
| 28 | - | |
| 29 | - except FileNotFoundError as e: | |
| 30 | - print(f"Error: {e}") | |
| 31 | - print(f"Create a config file at {config_path} first") | |
| 32 | - sys.exit(1) | |
| 33 | - except Exception as e: | |
| 34 | - print(f"Error: {e}") | |
| 35 | - sys.exit(1) | |
| 36 | - | |
| 37 | - | |
| 38 | -def cmd_add(args) -> None: | |
| 39 | - """Add an item to the config""" | |
| 40 | - if "=" not in args.assignment: | |
| 41 | - print("Error: Assignment must be in format key=value") | |
| 42 | - sys.exit(1) | |
| 43 | - | |
| 44 | - key, value = args.assignment.split("=", 1) | |
| 45 | - key = key.strip() | |
| 46 | - value = value.strip() | |
| 47 | - | |
| 48 | - if not key or not value: | |
| 49 | - print("Error: Both key and value must be non-empty") | |
| 50 | - sys.exit(1) | |
| 51 | - | |
| 52 | - config_path = Config.get_default_config_path() | |
| 53 | - | |
| 54 | - try: | |
| 55 | - config = Config(config_path, debug=getattr(args, "debug", False)) | |
| 56 | - # Try to load existing config, create empty if doesn't exist | |
| 57 | - try: | |
| 58 | - config.load() | |
| 59 | - except FileNotFoundError: | |
| 60 | - print(f"Creating new config file at {config_path}") | |
| 61 | - | |
| 62 | - config.add_item(args.type, args.group, key, value) | |
| 63 | - config.save() | |
| 64 | - | |
| 65 | - print(f"Added {args.type} '{key}' = '{value}' to group '{args.group}'") | |
| 66 | - | |
| 67 | - except Exception as e: | |
| 68 | - print(f"Error: {e}") | |
| 69 | - sys.exit(1) | |
| 70 | - | |
| 71 | - | |
| 72 | -def cmd_add_persistent(args) -> None: | |
| 73 | - """Add an item to the persistent group""" | |
| 74 | - if "=" not in args.assignment: | |
| 75 | - print("Error: Assignment must be in format key=value") | |
| 76 | - sys.exit(1) | |
| 77 | - | |
| 78 | - key, value = args.assignment.split("=", 1) | |
| 79 | - key = key.strip() | |
| 80 | - value = value.strip() | |
| 81 | - | |
| 82 | - if not key or not value: | |
| 83 | - print("Error: Both key and value must be non-empty") | |
| 84 | - sys.exit(1) | |
| 85 | - | |
| 86 | - config_path = Config.get_default_config_path() | |
| 87 | - | |
| 88 | - try: | |
| 89 | - config = Config(config_path, debug=getattr(args, "debug", False)) | |
| 90 | - # Try to load existing config, create empty if doesn't exist | |
| 91 | - try: | |
| 92 | - config.load() | |
| 93 | - except FileNotFoundError: | |
| 94 | - print(f"Creating new config file at {config_path}") | |
| 95 | - | |
| 96 | - config.add_item(args.type, "persistent", key, value) | |
| 97 | - config.save() | |
| 98 | - | |
| 99 | - print( | |
| 100 | - f"Added {args.type} '{key}' = '{value}' to persistent group (always active)" | |
| 101 | - ) | |
| 102 | - | |
| 103 | - # Auto-regenerate files if they exist | |
| 104 | - try: | |
| 105 | - generator = Generator() | |
| 106 | - persistent_group = config.get_persistent_group() | |
| 107 | - if persistent_group: | |
| 108 | - generator.generate_for_group(persistent_group) | |
| 109 | - generator.generate_loader(config) | |
| 110 | - print("Regenerated shell files with new persistent item") | |
| 111 | - except Exception as e: | |
| 112 | - print(f"Warning: Failed to regenerate files: {e}") | |
| 113 | - print("Run 'shtick generate' to update shell files") | |
| 114 | - | |
| 115 | - except Exception as e: | |
| 116 | - print(f"Error: {e}") | |
| 117 | - sys.exit(1) | |
| 118 | - | |
| 119 | - | |
| 120 | -def cmd_remove(args) -> None: | |
| 121 | - """Remove an item from the config""" | |
| 122 | - config_path = Config.get_default_config_path() | |
| 123 | - | |
| 124 | - try: | |
| 125 | - config = Config(config_path, debug=getattr(args, "debug", False)) | |
| 126 | - config.load() | |
| 127 | - | |
| 128 | - # Find matching items | |
| 129 | - matches = config.find_items(args.type, args.group, args.search) | |
| 130 | - | |
| 131 | - if not matches: | |
| 132 | - print( | |
| 133 | - f"No {args.type} items matching '{args.search}' found in group '{args.group}'" | |
| 134 | - ) | |
| 135 | - return | |
| 136 | - | |
| 137 | - if len(matches) == 1: | |
| 138 | - # Exact match, remove it | |
| 139 | - item = matches[0] | |
| 140 | - if config.remove_item(args.type, args.group, item): | |
| 141 | - config.save() | |
| 142 | - print(f"Removed {args.type} '{item}' from group '{args.group}'") | |
| 143 | - else: | |
| 144 | - print(f"Failed to remove {args.type} '{item}'") | |
| 145 | - else: | |
| 146 | - # Multiple matches, ask for confirmation | |
| 147 | - print(f"Found {len(matches)} matches:") | |
| 148 | - for i, item in enumerate(matches, 1): | |
| 149 | - print(f" {i}. {item}") | |
| 150 | - | |
| 151 | - try: | |
| 152 | - choice = input("Enter number to remove (or 'q' to quit): ").strip() | |
| 153 | - if choice.lower() == "q": | |
| 154 | - print("Cancelled") | |
| 155 | - return | |
| 156 | - | |
| 157 | - idx = int(choice) - 1 | |
| 158 | - if 0 <= idx < len(matches): | |
| 159 | - item = matches[idx] | |
| 160 | - if config.remove_item(args.type, args.group, item): | |
| 161 | - config.save() | |
| 162 | - print(f"Removed {args.type} '{item}' from group '{args.group}'") | |
| 163 | - else: | |
| 164 | - print(f"Failed to remove {args.type} '{item}'") | |
| 165 | - else: | |
| 166 | - print("Invalid choice") | |
| 167 | - except (ValueError, KeyboardInterrupt): | |
| 168 | - print("\nCancelled") | |
| 169 | - | |
| 170 | - except FileNotFoundError: | |
| 171 | - print(f"Config file not found: {config_path}") | |
| 172 | - sys.exit(1) | |
| 173 | - except Exception as e: | |
| 174 | - print(f"Error: {e}") | |
| 175 | - sys.exit(1) | |
| 176 | - | |
| 177 | - | |
| 178 | -def cmd_remove_persistent(args) -> None: | |
| 179 | - """Remove an item from the persistent group""" | |
| 180 | - config_path = Config.get_default_config_path() | |
| 181 | - | |
| 182 | - try: | |
| 183 | - config = Config(config_path, debug=getattr(args, "debug", False)) | |
| 184 | - config.load() | |
| 185 | - | |
| 186 | - # Find matching items in persistent group | |
| 187 | - matches = config.find_items(args.type, "persistent", args.search) | |
| 188 | - | |
| 189 | - if not matches: | |
| 190 | - print( | |
| 191 | - f"No {args.type} items matching '{args.search}' found in persistent group" | |
| 192 | - ) | |
| 193 | - return | |
| 194 | - | |
| 195 | - if len(matches) == 1: | |
| 196 | - # Exact match, remove it | |
| 197 | - item = matches[0] | |
| 198 | - if config.remove_item(args.type, "persistent", item): | |
| 199 | - config.save() | |
| 200 | - print(f"Removed {args.type} '{item}' from persistent group") | |
| 201 | - | |
| 202 | - # Auto-regenerate files | |
| 203 | - try: | |
| 204 | - generator = Generator() | |
| 205 | - persistent_group = config.get_persistent_group() | |
| 206 | - if persistent_group: | |
| 207 | - generator.generate_for_group(persistent_group) | |
| 208 | - generator.generate_loader(config) | |
| 209 | - print("Regenerated shell files") | |
| 210 | - except Exception as e: | |
| 211 | - print(f"Warning: Failed to regenerate files: {e}") | |
| 212 | - print("Run 'shtick generate' to update shell files") | |
| 213 | - else: | |
| 214 | - print(f"Failed to remove {args.type} '{item}'") | |
| 215 | - else: | |
| 216 | - # Multiple matches, ask for confirmation | |
| 217 | - print(f"Found {len(matches)} matches in persistent group:") | |
| 218 | - for i, item in enumerate(matches, 1): | |
| 219 | - print(f" {i}. {item}") | |
| 220 | - | |
| 221 | - try: | |
| 222 | - choice = input("Enter number to remove (or 'q' to quit): ").strip() | |
| 223 | - if choice.lower() == "q": | |
| 224 | - print("Cancelled") | |
| 225 | - return | |
| 226 | - | |
| 227 | - idx = int(choice) - 1 | |
| 228 | - if 0 <= idx < len(matches): | |
| 229 | - item = matches[idx] | |
| 230 | - if config.remove_item(args.type, "persistent", item): | |
| 231 | - config.save() | |
| 232 | - print(f"Removed {args.type} '{item}' from persistent group") | |
| 233 | - | |
| 234 | - # Auto-regenerate files | |
| 235 | - try: | |
| 236 | - generator = Generator() | |
| 237 | - persistent_group = config.get_persistent_group() | |
| 238 | - if persistent_group: | |
| 239 | - generator.generate_for_group(persistent_group) | |
| 240 | - generator.generate_loader(config) | |
| 241 | - print("Regenerated shell files") | |
| 242 | - except Exception as e: | |
| 243 | - print(f"Warning: Failed to regenerate files: {e}") | |
| 244 | - print("Run 'shtick generate' to update shell files") | |
| 245 | - else: | |
| 246 | - print(f"Failed to remove {args.type} '{item}'") | |
| 247 | - else: | |
| 248 | - print("Invalid choice") | |
| 249 | - except (ValueError, KeyboardInterrupt): | |
| 250 | - print("\nCancelled") | |
| 251 | - | |
| 252 | - except FileNotFoundError: | |
| 253 | - print(f"Config file not found: {config_path}") | |
| 254 | - sys.exit(1) | |
| 255 | - except Exception as e: | |
| 256 | - print(f"Error: {e}") | |
| 257 | - sys.exit(1) | |
| 258 | - | |
| 259 | - | |
| 260 | -def cmd_activate(args) -> None: | |
| 261 | - """Activate a group""" | |
| 262 | - config_path = Config.get_default_config_path() | |
| 263 | - | |
| 264 | - try: | |
| 265 | - config = Config(config_path, debug=getattr(args, "debug", False)) | |
| 266 | - config.load() | |
| 267 | - | |
| 268 | - if args.group == "persistent": | |
| 269 | - print( | |
| 270 | - "Error: 'persistent' group is always active and cannot be manually activated" | |
| 271 | - ) | |
| 272 | - return | |
| 273 | - | |
| 274 | - if config.activate_group(args.group): | |
| 275 | - # Regenerate all files to ensure they exist and are up to date | |
| 276 | - from shtick.generator import Generator | |
| 277 | - | |
| 278 | - generator = Generator() | |
| 279 | - # Generate shell files for all groups (not just the activated one) | |
| 280 | - for group in config.groups: | |
| 281 | - generator.generate_for_group(group) | |
| 282 | - # Then regenerate the loader to include newly activated group | |
| 283 | - generator.generate_loader(config) | |
| 284 | - | |
| 285 | - print(f"Activated group '{args.group}'") | |
| 286 | - print("Changes are now active in new shell sessions") | |
| 287 | - else: | |
| 288 | - print(f"Error: Group '{args.group}' not found in configuration") | |
| 289 | - available = [g.name for g in config.get_regular_groups()] | |
| 290 | - if available: | |
| 291 | - print(f"Available groups: {', '.join(available)}") | |
| 292 | - | |
| 293 | - except FileNotFoundError: | |
| 294 | - print(f"Config file not found: {config_path}") | |
| 295 | - print("Run 'shtick generate' first or add some items with 'shtick add'") | |
| 296 | - sys.exit(1) | |
| 297 | - except Exception as e: | |
| 298 | - print(f"Error: {e}") | |
| 299 | - sys.exit(1) | |
| 300 | - | |
| 301 | - | |
| 302 | -def cmd_deactivate(args) -> None: | |
| 303 | - """Deactivate a group""" | |
| 304 | - config_path = Config.get_default_config_path() | |
| 305 | - | |
| 306 | - try: | |
| 307 | - config = Config(config_path, debug=getattr(args, "debug", False)) | |
| 308 | - config.load() | |
| 309 | - | |
| 310 | - if args.group == "persistent": | |
| 311 | - print("Error: 'persistent' group cannot be deactivated") | |
| 312 | - return | |
| 313 | - | |
| 314 | - if config.deactivate_group(args.group): | |
| 315 | - # Regenerate loader to exclude deactivated group | |
| 316 | - from shtick.generator import Generator | |
| 317 | - | |
| 318 | - generator = Generator() | |
| 319 | - # Regenerate loader (no need to regenerate all files for deactivation) | |
| 320 | - generator.generate_loader(config) | |
| 321 | - | |
| 322 | - print(f"Deactivated group '{args.group}'") | |
| 323 | - print("Changes will take effect in new shell sessions") | |
| 324 | - else: | |
| 325 | - print(f"Group '{args.group}' was not active") | |
| 326 | - | |
| 327 | - except FileNotFoundError: | |
| 328 | - print(f"Config file not found: {config_path}") | |
| 329 | - sys.exit(1) | |
| 330 | - except Exception as e: | |
| 331 | - print(f"Error: {e}") | |
| 332 | - sys.exit(1) | |
| 333 | - | |
| 334 | - | |
| 335 | -def cmd_list(args) -> None: | |
| 336 | - """List current configuration""" | |
| 337 | - config_path = Config.get_default_config_path() | |
| 338 | - | |
| 339 | - try: | |
| 340 | - config = Config(config_path, debug=getattr(args, "debug", False)) | |
| 341 | - config.load() | |
| 342 | - | |
| 343 | - if not config.groups: | |
| 344 | - print("No groups configured") | |
| 345 | - return | |
| 346 | - | |
| 347 | - persistent_group = config.get_persistent_group() | |
| 348 | - regular_groups = config.get_regular_groups() | |
| 349 | - active_groups = config.load_active_groups() | |
| 350 | - | |
| 351 | - if args.long: | |
| 352 | - # Long format - detailed line-by-line | |
| 353 | - _print_detailed_list(persistent_group, regular_groups, active_groups) | |
| 354 | - else: | |
| 355 | - # Default tabular format | |
| 356 | - _print_tabular_list(persistent_group, regular_groups, active_groups) | |
| 357 | - | |
| 358 | - except FileNotFoundError: | |
| 359 | - print(f"Config file not found: {config_path}") | |
| 360 | - print( | |
| 361 | - "Use 'shtick add' to create entries or 'shtick generate' with a config file" | |
| 362 | - ) | |
| 363 | - except Exception as e: | |
| 364 | - print(f"Error: {e}") | |
| 365 | - sys.exit(1) | |
| 366 | - | |
| 367 | - | |
| 368 | -def _print_detailed_list(persistent_group, regular_groups, active_groups) -> None: | |
| 369 | - """Print detailed line-by-line list format""" | |
| 370 | - # Show persistent group first | |
| 371 | - if persistent_group: | |
| 372 | - print("Group: persistent (always active)") | |
| 373 | - if persistent_group.aliases: | |
| 374 | - print(f" Aliases ({len(persistent_group.aliases)}):") | |
| 375 | - for key, value in persistent_group.aliases.items(): | |
| 376 | - print(f" {key} = {value}") | |
| 377 | - if persistent_group.env_vars: | |
| 378 | - print(f" Environment Variables ({len(persistent_group.env_vars)}):") | |
| 379 | - for key, value in persistent_group.env_vars.items(): | |
| 380 | - print(f" {key} = {value}") | |
| 381 | - if persistent_group.functions: | |
| 382 | - print(f" Functions ({len(persistent_group.functions)}):") | |
| 383 | - for key, value in persistent_group.functions.items(): | |
| 384 | - print(f" {key} = {value}") | |
| 385 | - print() | |
| 386 | - | |
| 387 | - # Show regular groups | |
| 388 | - for group in regular_groups: | |
| 389 | - status = " (ACTIVE)" if group.name in active_groups else " (inactive)" | |
| 390 | - print(f"Group: {group.name}{status}") | |
| 391 | - | |
| 392 | - if group.aliases: | |
| 393 | - print(f" Aliases ({len(group.aliases)}):") | |
| 394 | - for key, value in group.aliases.items(): | |
| 395 | - print(f" {key} = {value}") | |
| 396 | - | |
| 397 | - if group.env_vars: | |
| 398 | - print(f" Environment Variables ({len(group.env_vars)}):") | |
| 399 | - for key, value in group.env_vars.items(): | |
| 400 | - print(f" {key} = {value}") | |
| 401 | - | |
| 402 | - if group.functions: | |
| 403 | - print(f" Functions ({len(group.functions)}):") | |
| 404 | - for key, value in group.functions.items(): | |
| 405 | - print(f" {key} = {value}") | |
| 406 | - print() | |
| 407 | - | |
| 408 | - | |
| 409 | -def _print_tabular_list(persistent_group, regular_groups, active_groups) -> None: | |
| 410 | - """Print compact tabular list format""" | |
| 411 | - # Collect all items for tabular display | |
| 412 | - items = [] | |
| 413 | - | |
| 414 | - # Add persistent items | |
| 415 | - if persistent_group: | |
| 416 | - for key, value in persistent_group.aliases.items(): | |
| 417 | - items.append(("persistent", "alias", key, value, "PERSISTENT")) | |
| 418 | - for key, value in persistent_group.env_vars.items(): | |
| 419 | - items.append(("persistent", "env", key, value, "PERSISTENT")) | |
| 420 | - for key, value in persistent_group.functions.items(): | |
| 421 | - items.append(("persistent", "function", key, value, "PERSISTENT")) | |
| 422 | - | |
| 423 | - # Add regular group items | |
| 424 | - for group in regular_groups: | |
| 425 | - status = "ACTIVE" if group.name in active_groups else "inactive" | |
| 426 | - | |
| 427 | - for key, value in group.aliases.items(): | |
| 428 | - items.append((group.name, "alias", key, value, status)) | |
| 429 | - for key, value in group.env_vars.items(): | |
| 430 | - items.append((group.name, "env", key, value, status)) | |
| 431 | - for key, value in group.functions.items(): | |
| 432 | - items.append((group.name, "function", key, value, status)) | |
| 433 | - | |
| 434 | - if not items: | |
| 435 | - print("No items configured") | |
| 436 | - return | |
| 437 | - | |
| 438 | - # Calculate column widths | |
| 439 | - max_group = max(len(item[0]) for item in items) | |
| 440 | - max_type = max(len(item[1]) for item in items) | |
| 441 | - max_key = max(len(item[2]) for item in items) | |
| 442 | - max_value = max(min(len(item[3]), 50) for item in items) # Limit value column width | |
| 443 | - max_status = max(len(item[4]) for item in items) | |
| 444 | - | |
| 445 | - # Ensure minimum widths | |
| 446 | - max_group = max(max_group, 5) # "Group" | |
| 447 | - max_type = max(max_type, 4) # "Type" | |
| 448 | - max_key = max(max_key, 3) # "Key" | |
| 449 | - max_value = max(max_value, 5) # "Value" | |
| 450 | - max_status = max(max_status, 6) # "Status" | |
| 451 | - | |
| 452 | - # Print header | |
| 453 | - header = f"{'Group':<{max_group}} {'Type':<{max_type}} {'Key':<{max_key}} {'Value':<{max_value}} {'Status':<{max_status}}" | |
| 454 | - print(header) | |
| 455 | - print("-" * len(header)) | |
| 456 | - | |
| 457 | - # Print items | |
| 458 | - for group, item_type, key, value, status in items: | |
| 459 | - # Truncate long values with ellipsis | |
| 460 | - display_value = ( | |
| 461 | - value if len(value) <= max_value else value[: max_value - 3] + "..." | |
| 462 | - ) | |
| 463 | - | |
| 464 | - print( | |
| 465 | - f"{group:<{max_group}} {item_type:<{max_type}} {key:<{max_key}} {display_value:<{max_value}} {status:<{max_status}}" | |
| 466 | - ) | |
| 467 | - | |
| 468 | - # Print summary | |
| 469 | - print() | |
| 470 | - total_items = len(items) | |
| 471 | - active_items = len([item for item in items if item[4] in ["ACTIVE", "PERSISTENT"]]) | |
| 472 | - print(f"Total: {total_items} items ({active_items} active)") | |
| 473 | - | |
| 474 | - # Show available commands | |
| 475 | - print() | |
| 476 | - print("Use 'shtick list -l' for detailed view") | |
| 477 | - if any(item[4] == "inactive" for item in items): | |
| 478 | - inactive_groups = set(item[0] for item in items if item[4] == "inactive") | |
| 479 | - print(f"Activate groups with: shtick activate <group>") | |
| 480 | - print(f"Inactive groups: {', '.join(sorted(inactive_groups))}") | |
| 481 | - | |
| 482 | - | |
| 483 | -def cmd_shells(args) -> None: | |
| 484 | - """List supported shells""" | |
| 485 | - shells = sorted(get_supported_shells()) | |
| 486 | - | |
| 487 | - if args.long: | |
| 488 | - # Long format - one per line with descriptions | |
| 489 | - print("Supported shells:") | |
| 490 | - for shell in shells: | |
| 491 | - print(f" {shell}") | |
| 492 | - else: | |
| 493 | - # Default columnar format (like ls) | |
| 494 | - _print_shells_columns(shells) | |
| 495 | - | |
| 496 | - | |
| 497 | -def _print_shells_columns(shells) -> None: | |
| 498 | - """Print shells in columns like ls output""" | |
| 499 | - if not shells: | |
| 500 | - print("No shells configured") | |
| 501 | - return | |
| 502 | - | |
| 503 | - # Try to get terminal width, fallback to 80 | |
| 504 | - try: | |
| 505 | - import shutil | |
| 506 | - | |
| 507 | - terminal_width = shutil.get_terminal_size().columns | |
| 508 | - except: | |
| 509 | - terminal_width = 80 | |
| 510 | - | |
| 511 | - # Find the longest shell name | |
| 512 | - max_shell_length = max(len(shell) for shell in shells) | |
| 513 | - | |
| 514 | - # Add some padding | |
| 515 | - column_width = max_shell_length + 2 | |
| 516 | - | |
| 517 | - # Calculate how many columns we can fit | |
| 518 | - columns = max(1, terminal_width // column_width) | |
| 519 | - | |
| 520 | - # Calculate number of rows needed | |
| 521 | - rows = (len(shells) + columns - 1) // columns | |
| 522 | - | |
| 523 | - print(f"Supported shells ({len(shells)} total):") | |
| 524 | - print() | |
| 525 | - | |
| 526 | - # Print shells in columns | |
| 527 | - for row in range(rows): | |
| 528 | - line = "" | |
| 529 | - for col in range(columns): | |
| 530 | - index = row + col * rows | |
| 531 | - if index < len(shells): | |
| 532 | - shell = shells[index] | |
| 533 | - line += f"{shell:<{column_width}}" | |
| 534 | - print(line.rstrip()) | |
| 535 | - | |
| 536 | - | |
| 537 | -def cmd_status(args) -> None: | |
| 538 | - """Show status of groups and active state""" | |
| 539 | - config_path = Config.get_default_config_path() | |
| 540 | - | |
| 541 | - try: | |
| 542 | - config = Config(config_path, debug=getattr(args, "debug", False)) | |
| 543 | - config.load() | |
| 544 | - | |
| 545 | - persistent_group = config.get_persistent_group() | |
| 546 | - regular_groups = config.get_regular_groups() | |
| 547 | - active_groups = config.load_active_groups() | |
| 548 | - | |
| 549 | - print("Shtick Status") | |
| 550 | - print("=" * 40) | |
| 551 | - | |
| 552 | - # Show persistent group | |
| 553 | - if persistent_group: | |
| 554 | - total_persistent = ( | |
| 555 | - len(persistent_group.aliases) | |
| 556 | - + len(persistent_group.env_vars) | |
| 557 | - + len(persistent_group.functions) | |
| 558 | - ) | |
| 559 | - print(f"Persistent (always active): {total_persistent} items") | |
| 560 | - else: | |
| 561 | - print("Persistent: No items") | |
| 562 | - | |
| 563 | - print() | |
| 564 | - | |
| 565 | - # Show regular groups | |
| 566 | - if regular_groups: | |
| 567 | - print("Available Groups:") | |
| 568 | - for group in regular_groups: | |
| 569 | - status = "ACTIVE" if group.name in active_groups else "inactive" | |
| 570 | - total_items = ( | |
| 571 | - len(group.aliases) + len(group.env_vars) + len(group.functions) | |
| 572 | - ) | |
| 573 | - print(f" {group.name}: {total_items} items ({status})") | |
| 574 | - else: | |
| 575 | - print("No regular groups configured") | |
| 576 | - | |
| 577 | - print() | |
| 578 | - | |
| 579 | - # Show summary | |
| 580 | - if active_groups: | |
| 581 | - print(f"Currently active: {', '.join(active_groups)}") | |
| 582 | - else: | |
| 583 | - print("No groups currently active") | |
| 584 | - | |
| 585 | - print() | |
| 586 | - print("To activate a group: shtick activate <group>") | |
| 587 | - print("To deactivate a group: shtick deactivate <group>") | |
| 588 | - print("To add persistent items: shtick add-persistent <type> <key>=<value>") | |
| 589 | - | |
| 590 | - except FileNotFoundError: | |
| 591 | - print(f"Config file not found: {config_path}") | |
| 592 | - print("No configuration exists yet") | |
| 593 | - except Exception as e: | |
| 594 | - print(f"Error: {e}") | |
| 595 | - sys.exit(1) | |
| 9 | +from shtick.commands import ShtickCommands | |
| 10 | +from shtick.display import DisplayCommands | |
| 596 | 11 | |
| 597 | 12 | |
| 598 | 13 | def main(): |
@@ -636,6 +51,24 @@ def main(): | ||
| 636 | 51 | "assignment", help="Assignment in format key=value" |
| 637 | 52 | ) |
| 638 | 53 | |
| 54 | + # Shorthand commands for common operations | |
| 55 | + alias_parser = subparsers.add_parser( | |
| 56 | + "alias", help="Add persistent alias (shorthand for 'add-persistent alias')" | |
| 57 | + ) | |
| 58 | + alias_parser.add_argument("assignment", help="Assignment in format key=value") | |
| 59 | + | |
| 60 | + env_parser = subparsers.add_parser( | |
| 61 | + "env", | |
| 62 | + help="Add persistent environment variable (shorthand for 'add-persistent env')", | |
| 63 | + ) | |
| 64 | + env_parser.add_argument("assignment", help="Assignment in format key=value") | |
| 65 | + | |
| 66 | + function_parser = subparsers.add_parser( | |
| 67 | + "function", | |
| 68 | + help="Add persistent function (shorthand for 'add-persistent function')", | |
| 69 | + ) | |
| 70 | + function_parser.add_argument("assignment", help="Assignment in format key=value") | |
| 71 | + | |
| 639 | 72 | # Remove command |
| 640 | 73 | rm_parser = subparsers.add_parser("remove", help="Remove an item from config") |
| 641 | 74 | rm_parser.add_argument( |
@@ -682,32 +115,58 @@ def main(): | ||
| 682 | 115 | help="Show one shell per line instead of columns", |
| 683 | 116 | ) |
| 684 | 117 | |
| 118 | + # Source command (for eval) | |
| 119 | + source_parser = subparsers.add_parser( | |
| 120 | + "source", help="Output source command for eval (for immediate loading)" | |
| 121 | + ) | |
| 122 | + source_parser.add_argument( | |
| 123 | + "--shell", help="Specify shell type (auto-detected if not provided)" | |
| 124 | + ) | |
| 125 | + | |
| 685 | 126 | args = parser.parse_args() |
| 686 | 127 | |
| 687 | 128 | if not args.command: |
| 129 | + # Show helpful getting started message | |
| 688 | 130 | parser.print_help() |
| 131 | + print("\nQuick start:") | |
| 132 | + print(" shtick alias ll='ls -la' # Add a persistent alias") | |
| 133 | + print(" shtick status # Show current configuration") | |
| 134 | + print(" shtick list # List all items") | |
| 689 | 135 | sys.exit(1) |
| 690 | 136 | |
| 137 | + # Initialize command handlers | |
| 138 | + commands = ShtickCommands(debug=args.debug) | |
| 139 | + display = DisplayCommands(debug=args.debug) | |
| 140 | + | |
| 141 | + # Route commands | |
| 691 | 142 | if args.command == "generate": |
| 692 | - cmd_generate(args) | |
| 143 | + commands.generate(args.config, args.terse) | |
| 693 | 144 | elif args.command == "add": |
| 694 | - cmd_add(args) | |
| 145 | + commands.add_item(args.type, args.group, args.assignment) | |
| 695 | 146 | elif args.command == "add-persistent": |
| 696 | - cmd_add_persistent(args) | |
| 147 | + commands.add_persistent(args.type, args.assignment) | |
| 148 | + elif args.command == "alias": | |
| 149 | + commands.add_persistent("alias", args.assignment) | |
| 150 | + elif args.command == "env": | |
| 151 | + commands.add_persistent("env", args.assignment) | |
| 152 | + elif args.command == "function": | |
| 153 | + commands.add_persistent("function", args.assignment) | |
| 697 | 154 | elif args.command == "remove": |
| 698 | - cmd_remove(args) | |
| 155 | + commands.remove_item(args.type, args.group, args.search) | |
| 699 | 156 | elif args.command == "remove-persistent": |
| 700 | - cmd_remove_persistent(args) | |
| 157 | + commands.remove_item(args.type, "persistent", args.search) | |
| 701 | 158 | elif args.command == "activate": |
| 702 | - cmd_activate(args) | |
| 159 | + commands.activate_group(args.group) | |
| 703 | 160 | elif args.command == "deactivate": |
| 704 | - cmd_deactivate(args) | |
| 161 | + commands.deactivate_group(args.group) | |
| 705 | 162 | elif args.command == "status": |
| 706 | - cmd_status(args) | |
| 163 | + display.status() | |
| 707 | 164 | elif args.command == "list": |
| 708 | - cmd_list(args) | |
| 165 | + display.list_config(args.long) | |
| 709 | 166 | elif args.command == "shells": |
| 710 | - cmd_shells(args) | |
| 167 | + display.shells(args.long) | |
| 168 | + elif args.command == "source": | |
| 169 | + commands.source_command(args.shell) | |
| 711 | 170 | |
| 712 | 171 | |
| 713 | 172 | if __name__ == "__main__": |
src/shtick/commands.pyadded@@ -0,0 +1,543 @@ | ||
| 1 | +""" | |
| 2 | +Command implementations for shtick CLI | |
| 3 | +""" | |
| 4 | + | |
| 5 | +import os | |
| 6 | +import sys | |
| 7 | +import subprocess | |
| 8 | +from typing import Optional, List | |
| 9 | + | |
| 10 | +from shtick.config import Config | |
| 11 | +from shtick.generator import Generator | |
| 12 | +from shtick.shells import get_supported_shells | |
| 13 | + | |
| 14 | + | |
| 15 | +class ShtickCommands: | |
| 16 | + """Central command handler for shtick operations""" | |
| 17 | + | |
| 18 | + def __init__(self, debug: bool = False): | |
| 19 | + self.debug = debug | |
| 20 | + | |
| 21 | + def get_current_shell(self) -> Optional[str]: | |
| 22 | + """Detect the current shell""" | |
| 23 | + shell_path = os.environ.get("SHELL", "") | |
| 24 | + return os.path.basename(shell_path) if shell_path else None | |
| 25 | + | |
| 26 | + def validate_assignment(self, assignment: str) -> tuple[str, str]: | |
| 27 | + """Validate key=value assignment format and return key, value""" | |
| 28 | + if "=" not in assignment: | |
| 29 | + raise ValueError("Assignment must be in format key=value") | |
| 30 | + | |
| 31 | + key, value = assignment.split("=", 1) | |
| 32 | + key = key.strip() | |
| 33 | + value = value.strip() | |
| 34 | + | |
| 35 | + if not key or not value: | |
| 36 | + raise ValueError("Both key and value must be non-empty") | |
| 37 | + | |
| 38 | + # Validate key format (basic shell identifier rules) | |
| 39 | + if not key.replace("_", "").replace("-", "").isalnum(): | |
| 40 | + raise ValueError( | |
| 41 | + "Key must contain only letters, numbers, underscores, and hyphens" | |
| 42 | + ) | |
| 43 | + | |
| 44 | + return key, value | |
| 45 | + | |
| 46 | + def check_conflicts( | |
| 47 | + self, config: Config, item_type: str, group_name: str, key: str | |
| 48 | + ) -> List[str]: | |
| 49 | + """Check for potential conflicts and warn user""" | |
| 50 | + warnings = [] | |
| 51 | + | |
| 52 | + # Check if item already exists in same group | |
| 53 | + group = config.get_group(group_name) | |
| 54 | + if group: | |
| 55 | + existing_value = None | |
| 56 | + if item_type == "alias" and key in group.aliases: | |
| 57 | + existing_value = group.aliases[key] | |
| 58 | + elif item_type == "env" and key in group.env_vars: | |
| 59 | + existing_value = group.env_vars[key] | |
| 60 | + elif item_type == "function" and key in group.functions: | |
| 61 | + existing_value = group.functions[key] | |
| 62 | + | |
| 63 | + if existing_value: | |
| 64 | + warnings.append( | |
| 65 | + f"Will overwrite existing {item_type} '{key}' = '{existing_value}' in group '{group_name}'" | |
| 66 | + ) | |
| 67 | + | |
| 68 | + # Check if item exists in other groups | |
| 69 | + for other_group in config.groups: | |
| 70 | + if other_group.name == group_name: | |
| 71 | + continue | |
| 72 | + | |
| 73 | + if item_type == "alias" and key in other_group.aliases: | |
| 74 | + warnings.append( | |
| 75 | + f"Alias '{key}' also exists in group '{other_group.name}' = '{other_group.aliases[key]}'" | |
| 76 | + ) | |
| 77 | + elif item_type == "env" and key in other_group.env_vars: | |
| 78 | + warnings.append( | |
| 79 | + f"Environment variable '{key}' also exists in group '{other_group.name}' = '{other_group.env_vars[key]}'" | |
| 80 | + ) | |
| 81 | + elif item_type == "function" and key in other_group.functions: | |
| 82 | + warnings.append( | |
| 83 | + f"Function '{key}' also exists in group '{other_group.name}' = '{other_group.functions[key]}'" | |
| 84 | + ) | |
| 85 | + | |
| 86 | + return warnings | |
| 87 | + | |
| 88 | + def handle_warnings(self, warnings: List[str]) -> bool: | |
| 89 | + """Handle conflict warnings and return True if user wants to continue""" | |
| 90 | + if not warnings: | |
| 91 | + return True | |
| 92 | + | |
| 93 | + print("Warnings:") | |
| 94 | + for warning in warnings: | |
| 95 | + print(f" - {warning}") | |
| 96 | + try: | |
| 97 | + response = input("Continue anyway? [y/N]: ").strip().lower() | |
| 98 | + return response in ["y", "yes"] | |
| 99 | + except (KeyboardInterrupt, EOFError): | |
| 100 | + print("\nCancelled") | |
| 101 | + return False | |
| 102 | + | |
| 103 | + def regenerate_and_offer_source(self, config: Config, group_name: str = None): | |
| 104 | + """Regenerate shell files and offer auto-sourcing""" | |
| 105 | + try: | |
| 106 | + generator = Generator() | |
| 107 | + | |
| 108 | + if group_name: | |
| 109 | + group = config.get_group(group_name) | |
| 110 | + if group: | |
| 111 | + generator.generate_for_group(group) | |
| 112 | + else: | |
| 113 | + # Regenerate all groups | |
| 114 | + for group in config.groups: | |
| 115 | + generator.generate_for_group(group) | |
| 116 | + | |
| 117 | + generator.generate_loader(config) | |
| 118 | + print("✓ Regenerated shell files") | |
| 119 | + self.offer_auto_source() | |
| 120 | + except Exception as e: | |
| 121 | + print(f"Warning: Failed to regenerate files: {e}") | |
| 122 | + print("Run 'shtick generate' to update shell files") | |
| 123 | + | |
| 124 | + def offer_auto_source(self): | |
| 125 | + """Offer to source shtick in current shell session""" | |
| 126 | + current_shell = self.get_current_shell() | |
| 127 | + if not current_shell or current_shell not in ["bash", "zsh", "fish"]: | |
| 128 | + return | |
| 129 | + | |
| 130 | + loader_path = os.path.expanduser( | |
| 131 | + f"~/.config/shtick/load_active.{current_shell}" | |
| 132 | + ) | |
| 133 | + if not os.path.exists(loader_path): | |
| 134 | + print("Loader file not found. Run 'shtick generate' first.") | |
| 135 | + return | |
| 136 | + | |
| 137 | + try: | |
| 138 | + response = ( | |
| 139 | + input(f"\nSource shtick in current {current_shell} session? [Y/n]: ") | |
| 140 | + .strip() | |
| 141 | + .lower() | |
| 142 | + ) | |
| 143 | + if response in ["", "y", "yes"]: | |
| 144 | + # Test syntax first | |
| 145 | + if self._test_loader_syntax(current_shell, loader_path): | |
| 146 | + self._show_source_instructions(current_shell) | |
| 147 | + except (KeyboardInterrupt, EOFError): | |
| 148 | + print() | |
| 149 | + | |
| 150 | + def _test_loader_syntax(self, shell: str, loader_path: str) -> bool: | |
| 151 | + """Test loader file syntax and return True if valid""" | |
| 152 | + try: | |
| 153 | + test_response = ( | |
| 154 | + input("Test the loader file for syntax errors? [Y/n]: ").strip().lower() | |
| 155 | + ) | |
| 156 | + if test_response not in ["", "y", "yes"]: | |
| 157 | + return True | |
| 158 | + | |
| 159 | + result = subprocess.run( | |
| 160 | + [shell, "-c", f'source "{loader_path}"'], | |
| 161 | + capture_output=True, | |
| 162 | + text=True, | |
| 163 | + timeout=5, | |
| 164 | + ) | |
| 165 | + | |
| 166 | + if result.returncode == 0: | |
| 167 | + print("✓ Loader file syntax is valid") | |
| 168 | + return True | |
| 169 | + else: | |
| 170 | + print("✗ Loader file has syntax errors:") | |
| 171 | + print(result.stderr) | |
| 172 | + print("Please fix the errors before sourcing.") | |
| 173 | + return False | |
| 174 | + except subprocess.TimeoutExpired: | |
| 175 | + print("✓ Loader file appears to work (test timed out, which is normal)") | |
| 176 | + return True | |
| 177 | + except Exception as e: | |
| 178 | + print(f"Could not test loader file: {e}") | |
| 179 | + return True | |
| 180 | + | |
| 181 | + def _show_source_instructions(self, shell: str): | |
| 182 | + """Show instructions for sourcing""" | |
| 183 | + print(f"\n🎯 Copy and paste this command to load changes immediately:") | |
| 184 | + if shell == "fish": | |
| 185 | + print(f"eval (shtick source)") | |
| 186 | + else: | |
| 187 | + print(f'eval "$(shtick source)"') | |
| 188 | + | |
| 189 | + print(f"\nOr run directly:") | |
| 190 | + print(f"source ~/.config/shtick/load_active.{shell}") | |
| 191 | + | |
| 192 | + print("\n✨ Changes will be available in new shell sessions automatically.") | |
| 193 | + self._show_eval_hint(shell) | |
| 194 | + | |
| 195 | + def _show_eval_hint(self, shell: str): | |
| 196 | + """Show eval hint for easier future use""" | |
| 197 | + print(f"\n💡 To automatically source shtick when making changes, use:") | |
| 198 | + print(f'eval "$(shtick source)"') | |
| 199 | + print(f"\nOr add this alias to your shell config:") | |
| 200 | + if shell in ["bash", "zsh"]: | |
| 201 | + print(f"alias ss='eval \"$(shtick source)\"'") | |
| 202 | + elif shell == "fish": | |
| 203 | + print(f"alias ss 'eval (shtick source)'") | |
| 204 | + print(f"Then just run 'ss' after adding aliases!") | |
| 205 | + | |
| 206 | + def check_shell_integration(self): | |
| 207 | + """Check if shtick is properly integrated with shell config""" | |
| 208 | + current_shell = self.get_current_shell() | |
| 209 | + if not current_shell: | |
| 210 | + return | |
| 211 | + | |
| 212 | + shell_configs = { | |
| 213 | + "bash": ["~/.bashrc", "~/.bash_profile"], | |
| 214 | + "zsh": ["~/.zshrc", "~/.zprofile"], | |
| 215 | + "fish": ["~/.config/fish/config.fish"], | |
| 216 | + } | |
| 217 | + | |
| 218 | + if current_shell not in shell_configs: | |
| 219 | + return | |
| 220 | + | |
| 221 | + loader_line = f"source ~/.config/shtick/load_active.{current_shell}" | |
| 222 | + | |
| 223 | + # Check if already integrated | |
| 224 | + for config_file in shell_configs[current_shell]: | |
| 225 | + expanded_path = os.path.expanduser(config_file) | |
| 226 | + if os.path.exists(expanded_path): | |
| 227 | + try: | |
| 228 | + with open(expanded_path, "r") as f: | |
| 229 | + content = f.read() | |
| 230 | + if "shtick" in content or loader_line in content: | |
| 231 | + return # Already integrated | |
| 232 | + except: | |
| 233 | + continue | |
| 234 | + | |
| 235 | + # Not integrated, offer to add | |
| 236 | + try: | |
| 237 | + print( | |
| 238 | + f"\n🔧 Shtick is not integrated with your {current_shell} configuration." | |
| 239 | + ) | |
| 240 | + print("This means your aliases won't be available in new shell sessions.") | |
| 241 | + response = ( | |
| 242 | + input("Add shtick to your shell config automatically? [Y/n]: ") | |
| 243 | + .strip() | |
| 244 | + .lower() | |
| 245 | + ) | |
| 246 | + if response in ["", "y", "yes"]: | |
| 247 | + if self._add_shell_integration( | |
| 248 | + current_shell, shell_configs[current_shell] | |
| 249 | + ): | |
| 250 | + print( | |
| 251 | + "\n✓ Integration complete! Your aliases will be available in new shell sessions." | |
| 252 | + ) | |
| 253 | + print("To use them immediately in this session, run:") | |
| 254 | + print(f" source ~/.config/shtick/load_active.{current_shell}") | |
| 255 | + else: | |
| 256 | + print( | |
| 257 | + f"\nTo manually integrate later, add this line to your {current_shell} config:" | |
| 258 | + ) | |
| 259 | + print(f" {loader_line}") | |
| 260 | + except (KeyboardInterrupt, EOFError): | |
| 261 | + print() | |
| 262 | + | |
| 263 | + def _add_shell_integration(self, shell: str, config_files: List[str]) -> bool: | |
| 264 | + """Add shtick integration to shell config""" | |
| 265 | + loader_line = f"source ~/.config/shtick/load_active.{shell}" | |
| 266 | + | |
| 267 | + # Try to find the best config file to modify | |
| 268 | + for config_file in config_files: | |
| 269 | + expanded_path = os.path.expanduser(config_file) | |
| 270 | + if os.path.exists(expanded_path): | |
| 271 | + try: | |
| 272 | + with open(expanded_path, "a") as f: | |
| 273 | + f.write(f"\n# Shtick shell configuration manager\n") | |
| 274 | + f.write(f"{loader_line}\n") | |
| 275 | + print(f"✓ Added shtick integration to {config_file}") | |
| 276 | + return True | |
| 277 | + except Exception as e: | |
| 278 | + print(f"✗ Failed to modify {config_file}: {e}") | |
| 279 | + continue | |
| 280 | + | |
| 281 | + # If no existing config file found, create the primary one | |
| 282 | + primary_config = config_files[0] | |
| 283 | + expanded_path = os.path.expanduser(primary_config) | |
| 284 | + try: | |
| 285 | + os.makedirs(os.path.dirname(expanded_path), exist_ok=True) | |
| 286 | + with open(expanded_path, "w") as f: | |
| 287 | + f.write(f"# Shtick shell configuration manager\n") | |
| 288 | + f.write(f"{loader_line}\n") | |
| 289 | + print(f"✓ Created {primary_config} with shtick integration") | |
| 290 | + return True | |
| 291 | + except Exception as e: | |
| 292 | + print(f"✗ Failed to create {primary_config}: {e}") | |
| 293 | + return False | |
| 294 | + | |
| 295 | + # Command implementations | |
| 296 | + def generate(self, config_path: str = None, terse: bool = False): | |
| 297 | + """Generate shell files from config""" | |
| 298 | + config_path = config_path or Config.get_default_config_path() | |
| 299 | + | |
| 300 | + try: | |
| 301 | + config = Config(config_path, debug=self.debug) | |
| 302 | + config.load() | |
| 303 | + | |
| 304 | + generator = Generator() | |
| 305 | + generator.generate_all(config, interactive=not terse) | |
| 306 | + | |
| 307 | + if not terse: | |
| 308 | + self.check_shell_integration() | |
| 309 | + | |
| 310 | + except FileNotFoundError as e: | |
| 311 | + print(f"Error: {e}") | |
| 312 | + print(f"Create a config file at {config_path} first") | |
| 313 | + sys.exit(1) | |
| 314 | + except Exception as e: | |
| 315 | + print(f"Error: {e}") | |
| 316 | + sys.exit(1) | |
| 317 | + | |
| 318 | + def add_item(self, item_type: str, group: str, assignment: str): | |
| 319 | + """Add an item to a specific group""" | |
| 320 | + try: | |
| 321 | + key, value = self.validate_assignment(assignment) | |
| 322 | + except ValueError as e: | |
| 323 | + print(f"Error: {e}") | |
| 324 | + sys.exit(1) | |
| 325 | + | |
| 326 | + config_path = Config.get_default_config_path() | |
| 327 | + | |
| 328 | + try: | |
| 329 | + config = Config(config_path, debug=self.debug) | |
| 330 | + try: | |
| 331 | + config.load() | |
| 332 | + except FileNotFoundError: | |
| 333 | + print(f"Creating new config file at {config_path}") | |
| 334 | + | |
| 335 | + # Check for conflicts and get user confirmation | |
| 336 | + warnings = self.check_conflicts(config, item_type, group, key) | |
| 337 | + if not self.handle_warnings(warnings): | |
| 338 | + return | |
| 339 | + | |
| 340 | + config.add_item(item_type, group, key, value) | |
| 341 | + config.save() | |
| 342 | + | |
| 343 | + print(f"✓ Added {item_type} '{key}' = '{value}' to group '{group}'") | |
| 344 | + | |
| 345 | + # Auto-regenerate if group is active | |
| 346 | + if config.is_group_active(group) or group == "persistent": | |
| 347 | + self.regenerate_and_offer_source(config, group) | |
| 348 | + | |
| 349 | + except Exception as e: | |
| 350 | + print(f"Error: {e}") | |
| 351 | + sys.exit(1) | |
| 352 | + | |
| 353 | + def add_persistent(self, item_type: str, assignment: str): | |
| 354 | + """Add an item to the persistent group""" | |
| 355 | + try: | |
| 356 | + key, value = self.validate_assignment(assignment) | |
| 357 | + except ValueError as e: | |
| 358 | + print(f"Error: {e}") | |
| 359 | + sys.exit(1) | |
| 360 | + | |
| 361 | + config_path = Config.get_default_config_path() | |
| 362 | + is_first_time = not os.path.exists(config_path) | |
| 363 | + | |
| 364 | + try: | |
| 365 | + config = Config(config_path, debug=self.debug) | |
| 366 | + try: | |
| 367 | + config.load() | |
| 368 | + except FileNotFoundError: | |
| 369 | + print(f"Creating new config file at {config_path}") | |
| 370 | + | |
| 371 | + # Check for conflicts | |
| 372 | + warnings = self.check_conflicts(config, item_type, "persistent", key) | |
| 373 | + if not self.handle_warnings(warnings): | |
| 374 | + return | |
| 375 | + | |
| 376 | + config.add_item(item_type, "persistent", key, value) | |
| 377 | + config.save() | |
| 378 | + | |
| 379 | + print( | |
| 380 | + f"✓ Added {item_type} '{key}' = '{value}' to persistent group (always active)" | |
| 381 | + ) | |
| 382 | + | |
| 383 | + # Auto-regenerate files | |
| 384 | + self.regenerate_and_offer_source(config, "persistent") | |
| 385 | + | |
| 386 | + # First-time setup experience | |
| 387 | + if is_first_time: | |
| 388 | + print("\n🎉 Welcome to shtick!") | |
| 389 | + self.check_shell_integration() | |
| 390 | + | |
| 391 | + except Exception as e: | |
| 392 | + print(f"Error: {e}") | |
| 393 | + sys.exit(1) | |
| 394 | + | |
| 395 | + def remove_item(self, item_type: str, group: str, search: str): | |
| 396 | + """Remove an item from a group""" | |
| 397 | + config_path = Config.get_default_config_path() | |
| 398 | + | |
| 399 | + try: | |
| 400 | + config = Config(config_path, debug=self.debug) | |
| 401 | + config.load() | |
| 402 | + | |
| 403 | + # Find matching items | |
| 404 | + matches = config.find_items(item_type, group, search) | |
| 405 | + | |
| 406 | + if not matches: | |
| 407 | + print( | |
| 408 | + f"No {item_type} items matching '{search}' found in group '{group}'" | |
| 409 | + ) | |
| 410 | + return | |
| 411 | + | |
| 412 | + # Handle single vs multiple matches | |
| 413 | + item_to_remove = self._select_item_to_remove(matches) | |
| 414 | + if not item_to_remove: | |
| 415 | + return | |
| 416 | + | |
| 417 | + if config.remove_item(item_type, group, item_to_remove): | |
| 418 | + config.save() | |
| 419 | + print(f"✓ Removed {item_type} '{item_to_remove}' from group '{group}'") | |
| 420 | + | |
| 421 | + # Auto-regenerate if group is active | |
| 422 | + if config.is_group_active(group) or group == "persistent": | |
| 423 | + self.regenerate_and_offer_source(config, group) | |
| 424 | + else: | |
| 425 | + print(f"Failed to remove {item_type} '{item_to_remove}'") | |
| 426 | + | |
| 427 | + except FileNotFoundError: | |
| 428 | + print(f"Config file not found: {config_path}") | |
| 429 | + sys.exit(1) | |
| 430 | + except Exception as e: | |
| 431 | + print(f"Error: {e}") | |
| 432 | + sys.exit(1) | |
| 433 | + | |
| 434 | + def _select_item_to_remove(self, matches: List[str]) -> Optional[str]: | |
| 435 | + """Handle selection of item to remove from matches""" | |
| 436 | + if len(matches) == 1: | |
| 437 | + return matches[0] | |
| 438 | + | |
| 439 | + # Multiple matches, ask for confirmation | |
| 440 | + print(f"Found {len(matches)} matches:") | |
| 441 | + for i, item in enumerate(matches, 1): | |
| 442 | + print(f" {i}. {item}") | |
| 443 | + | |
| 444 | + try: | |
| 445 | + choice = input("Enter number to remove (or 'q' to quit): ").strip() | |
| 446 | + if choice.lower() == "q": | |
| 447 | + print("Cancelled") | |
| 448 | + return None | |
| 449 | + | |
| 450 | + idx = int(choice) - 1 | |
| 451 | + if 0 <= idx < len(matches): | |
| 452 | + return matches[idx] | |
| 453 | + else: | |
| 454 | + print("Invalid choice") | |
| 455 | + return None | |
| 456 | + except (ValueError, KeyboardInterrupt): | |
| 457 | + print("\nCancelled") | |
| 458 | + return None | |
| 459 | + | |
| 460 | + def activate_group(self, group_name: str): | |
| 461 | + """Activate a group""" | |
| 462 | + config_path = Config.get_default_config_path() | |
| 463 | + | |
| 464 | + try: | |
| 465 | + config = Config(config_path, debug=self.debug) | |
| 466 | + config.load() | |
| 467 | + | |
| 468 | + if group_name == "persistent": | |
| 469 | + print( | |
| 470 | + "Error: 'persistent' group is always active and cannot be manually activated" | |
| 471 | + ) | |
| 472 | + return | |
| 473 | + | |
| 474 | + if config.activate_group(group_name): | |
| 475 | + self.regenerate_and_offer_source(config) | |
| 476 | + print(f"✓ Activated group '{group_name}'") | |
| 477 | + print("Changes are now active in new shell sessions") | |
| 478 | + else: | |
| 479 | + print(f"Error: Group '{group_name}' not found in configuration") | |
| 480 | + available = [g.name for g in config.get_regular_groups()] | |
| 481 | + if available: | |
| 482 | + print(f"Available groups: {', '.join(available)}") | |
| 483 | + | |
| 484 | + except FileNotFoundError: | |
| 485 | + print(f"Config file not found: {config_path}") | |
| 486 | + print("Run 'shtick generate' first or add some items with 'shtick add'") | |
| 487 | + sys.exit(1) | |
| 488 | + except Exception as e: | |
| 489 | + print(f"Error: {e}") | |
| 490 | + sys.exit(1) | |
| 491 | + | |
| 492 | + def deactivate_group(self, group_name: str): | |
| 493 | + """Deactivate a group""" | |
| 494 | + config_path = Config.get_default_config_path() | |
| 495 | + | |
| 496 | + try: | |
| 497 | + config = Config(config_path, debug=self.debug) | |
| 498 | + config.load() | |
| 499 | + | |
| 500 | + if group_name == "persistent": | |
| 501 | + print("Error: 'persistent' group cannot be deactivated") | |
| 502 | + return | |
| 503 | + | |
| 504 | + if config.deactivate_group(group_name): | |
| 505 | + # Only need to regenerate loader for deactivation | |
| 506 | + try: | |
| 507 | + generator = Generator() | |
| 508 | + generator.generate_loader(config) | |
| 509 | + print("✓ Regenerated shell files") | |
| 510 | + except Exception as e: | |
| 511 | + print(f"Warning: Failed to regenerate files: {e}") | |
| 512 | + | |
| 513 | + print(f"✓ Deactivated group '{group_name}'") | |
| 514 | + print("Changes will take effect in new shell sessions") | |
| 515 | + self.offer_auto_source() | |
| 516 | + else: | |
| 517 | + print(f"Group '{group_name}' was not active") | |
| 518 | + | |
| 519 | + except FileNotFoundError: | |
| 520 | + print(f"Config file not found: {config_path}") | |
| 521 | + sys.exit(1) | |
| 522 | + except Exception as e: | |
| 523 | + print(f"Error: {e}") | |
| 524 | + sys.exit(1) | |
| 525 | + | |
| 526 | + def source_command(self, shell: str = None): | |
| 527 | + """Output source command for eval""" | |
| 528 | + current_shell = shell or self.get_current_shell() | |
| 529 | + | |
| 530 | + if not current_shell: | |
| 531 | + print("Could not detect shell. Use --shell to specify.", file=sys.stderr) | |
| 532 | + sys.exit(1) | |
| 533 | + | |
| 534 | + loader_path = os.path.expanduser( | |
| 535 | + f"~/.config/shtick/load_active.{current_shell}" | |
| 536 | + ) | |
| 537 | + if not os.path.exists(loader_path): | |
| 538 | + print(f"Loader file not found: {loader_path}", file=sys.stderr) | |
| 539 | + print("Run 'shtick generate' first.", file=sys.stderr) | |
| 540 | + sys.exit(1) | |
| 541 | + | |
| 542 | + # Output the source command that can be eval'd | |
| 543 | + print(f"source {loader_path}") | |
src/shtick/display.pyadded@@ -0,0 +1,283 @@ | ||
| 1 | +""" | |
| 2 | +Display commands for shtick CLI - handles listing, status, and informational output | |
| 3 | +""" | |
| 4 | + | |
| 5 | +import os | |
| 6 | +import sys | |
| 7 | +from shtick.config import Config | |
| 8 | +from shtick.shells import get_supported_shells | |
| 9 | + | |
| 10 | + | |
| 11 | +class DisplayCommands: | |
| 12 | + """Handles all display/listing commands for shtick""" | |
| 13 | + | |
| 14 | + def __init__(self, debug: bool = False): | |
| 15 | + self.debug = debug | |
| 16 | + | |
| 17 | + def get_current_shell(self): | |
| 18 | + """Detect the current shell""" | |
| 19 | + shell_path = os.environ.get("SHELL", "") | |
| 20 | + return os.path.basename(shell_path) if shell_path else None | |
| 21 | + | |
| 22 | + def status(self): | |
| 23 | + """Show status of groups and active state""" | |
| 24 | + config_path = Config.get_default_config_path() | |
| 25 | + | |
| 26 | + try: | |
| 27 | + config = Config(config_path, debug=self.debug) | |
| 28 | + config.load() | |
| 29 | + | |
| 30 | + persistent_group = config.get_persistent_group() | |
| 31 | + regular_groups = config.get_regular_groups() | |
| 32 | + active_groups = config.load_active_groups() | |
| 33 | + | |
| 34 | + print("Shtick Status") | |
| 35 | + print("=" * 40) | |
| 36 | + | |
| 37 | + # Show current shell integration status | |
| 38 | + current_shell = self.get_current_shell() | |
| 39 | + if current_shell: | |
| 40 | + print(f"Current shell: {current_shell}") | |
| 41 | + loader_path = os.path.expanduser( | |
| 42 | + f"~/.config/shtick/load_active.{current_shell}" | |
| 43 | + ) | |
| 44 | + if os.path.exists(loader_path): | |
| 45 | + print(f"Loader file: ✓ exists") | |
| 46 | + else: | |
| 47 | + print(f"Loader file: ✗ missing (run 'shtick generate')") | |
| 48 | + print() | |
| 49 | + | |
| 50 | + # Show persistent group | |
| 51 | + if persistent_group: | |
| 52 | + total_persistent = ( | |
| 53 | + len(persistent_group.aliases) | |
| 54 | + + len(persistent_group.env_vars) | |
| 55 | + + len(persistent_group.functions) | |
| 56 | + ) | |
| 57 | + print(f"Persistent (always active): {total_persistent} items") | |
| 58 | + else: | |
| 59 | + print("Persistent: No items") | |
| 60 | + | |
| 61 | + print() | |
| 62 | + | |
| 63 | + # Show regular groups | |
| 64 | + if regular_groups: | |
| 65 | + print("Available Groups:") | |
| 66 | + for group in regular_groups: | |
| 67 | + status = "ACTIVE" if group.name in active_groups else "inactive" | |
| 68 | + total_items = ( | |
| 69 | + len(group.aliases) + len(group.env_vars) + len(group.functions) | |
| 70 | + ) | |
| 71 | + print(f" {group.name}: {total_items} items ({status})") | |
| 72 | + else: | |
| 73 | + print("No regular groups configured") | |
| 74 | + | |
| 75 | + print() | |
| 76 | + | |
| 77 | + # Show summary | |
| 78 | + if active_groups: | |
| 79 | + print(f"Currently active: {', '.join(active_groups)}") | |
| 80 | + else: | |
| 81 | + print("No groups currently active") | |
| 82 | + | |
| 83 | + print() | |
| 84 | + print("Quick commands:") | |
| 85 | + print(" shtick alias ll='ls -la' # Add persistent alias") | |
| 86 | + print(" shtick activate <group> # Activate group") | |
| 87 | + print(' eval "$(shtick source)" # Load changes now') | |
| 88 | + | |
| 89 | + except FileNotFoundError: | |
| 90 | + print(f"Config file not found: {config_path}") | |
| 91 | + print("No configuration exists yet") | |
| 92 | + print("\nGet started with:") | |
| 93 | + print(" shtick alias ll='ls -la'") | |
| 94 | + except Exception as e: | |
| 95 | + print(f"Error: {e}") | |
| 96 | + sys.exit(1) | |
| 97 | + | |
| 98 | + def list_config(self, long_format: bool = False): | |
| 99 | + """List current configuration""" | |
| 100 | + config_path = Config.get_default_config_path() | |
| 101 | + | |
| 102 | + try: | |
| 103 | + config = Config(config_path, debug=self.debug) | |
| 104 | + config.load() | |
| 105 | + | |
| 106 | + if not config.groups: | |
| 107 | + print("No groups configured") | |
| 108 | + print("\nGet started with:") | |
| 109 | + print(" shtick alias ll='ls -la' # Add persistent alias") | |
| 110 | + print(" shtick add alias work ll='ls -la' # Add to 'work' group") | |
| 111 | + print(" shtick activate work # Activate 'work' group") | |
| 112 | + return | |
| 113 | + | |
| 114 | + persistent_group = config.get_persistent_group() | |
| 115 | + regular_groups = config.get_regular_groups() | |
| 116 | + active_groups = config.load_active_groups() | |
| 117 | + | |
| 118 | + if long_format: | |
| 119 | + self._print_detailed_list( | |
| 120 | + persistent_group, regular_groups, active_groups | |
| 121 | + ) | |
| 122 | + else: | |
| 123 | + self._print_tabular_list( | |
| 124 | + persistent_group, regular_groups, active_groups | |
| 125 | + ) | |
| 126 | + | |
| 127 | + except FileNotFoundError: | |
| 128 | + print(f"Config file not found: {config_path}") | |
| 129 | + print("Use 'shtick alias <key>=<value>' to create your first alias") | |
| 130 | + except Exception as e: | |
| 131 | + print(f"Error: {e}") | |
| 132 | + sys.exit(1) | |
| 133 | + | |
| 134 | + def _print_detailed_list(self, persistent_group, regular_groups, active_groups): | |
| 135 | + """Print detailed line-by-line list format""" | |
| 136 | + # Show persistent group first | |
| 137 | + if persistent_group: | |
| 138 | + print("Group: persistent (always active)") | |
| 139 | + self._print_group_items(persistent_group) | |
| 140 | + print() | |
| 141 | + | |
| 142 | + # Show regular groups | |
| 143 | + for group in regular_groups: | |
| 144 | + status = " (ACTIVE)" if group.name in active_groups else " (inactive)" | |
| 145 | + print(f"Group: {group.name}{status}") | |
| 146 | + self._print_group_items(group) | |
| 147 | + print() | |
| 148 | + | |
| 149 | + def _print_group_items(self, group): | |
| 150 | + """Print items for a single group""" | |
| 151 | + if group.aliases: | |
| 152 | + print(f" Aliases ({len(group.aliases)}):") | |
| 153 | + for key, value in group.aliases.items(): | |
| 154 | + print(f" {key} = {value}") | |
| 155 | + if group.env_vars: | |
| 156 | + print(f" Environment Variables ({len(group.env_vars)}):") | |
| 157 | + for key, value in group.env_vars.items(): | |
| 158 | + print(f" {key} = {value}") | |
| 159 | + if group.functions: | |
| 160 | + print(f" Functions ({len(group.functions)}):") | |
| 161 | + for key, value in group.functions.items(): | |
| 162 | + print(f" {key} = {value}") | |
| 163 | + | |
| 164 | + def _print_tabular_list(self, persistent_group, regular_groups, active_groups): | |
| 165 | + """Print compact tabular list format""" | |
| 166 | + # Collect all items for tabular display | |
| 167 | + items = [] | |
| 168 | + | |
| 169 | + # Add persistent items | |
| 170 | + if persistent_group: | |
| 171 | + items.extend(self._collect_group_items(persistent_group, "PERSISTENT")) | |
| 172 | + | |
| 173 | + # Add regular group items | |
| 174 | + for group in regular_groups: | |
| 175 | + status = "ACTIVE" if group.name in active_groups else "inactive" | |
| 176 | + items.extend(self._collect_group_items(group, status)) | |
| 177 | + | |
| 178 | + if not items: | |
| 179 | + print("No items configured") | |
| 180 | + return | |
| 181 | + | |
| 182 | + self._print_table(items) | |
| 183 | + self._print_summary(items, active_groups) | |
| 184 | + | |
| 185 | + def _collect_group_items(self, group, status): | |
| 186 | + """Collect items from a group for tabular display""" | |
| 187 | + items = [] | |
| 188 | + for key, value in group.aliases.items(): | |
| 189 | + items.append((group.name, "alias", key, value, status)) | |
| 190 | + for key, value in group.env_vars.items(): | |
| 191 | + items.append((group.name, "env", key, value, status)) | |
| 192 | + for key, value in group.functions.items(): | |
| 193 | + items.append((group.name, "function", key, value, status)) | |
| 194 | + return items | |
| 195 | + | |
| 196 | + def _print_table(self, items): | |
| 197 | + """Print items in tabular format""" | |
| 198 | + # Calculate column widths | |
| 199 | + max_group = max(max(len(item[0]) for item in items), 5) # "Group" | |
| 200 | + max_type = max(max(len(item[1]) for item in items), 4) # "Type" | |
| 201 | + max_key = max(max(len(item[2]) for item in items), 3) # "Key" | |
| 202 | + max_value = max( | |
| 203 | + max(min(len(item[3]), 50) for item in items), 5 | |
| 204 | + ) # "Value" (limited) | |
| 205 | + max_status = max(max(len(item[4]) for item in items), 6) # "Status" | |
| 206 | + | |
| 207 | + # Print header | |
| 208 | + header = f"{'Group':<{max_group}} {'Type':<{max_type}} {'Key':<{max_key}} {'Value':<{max_value}} {'Status':<{max_status}}" | |
| 209 | + print(header) | |
| 210 | + print("-" * len(header)) | |
| 211 | + | |
| 212 | + # Print items | |
| 213 | + for group, item_type, key, value, status in items: | |
| 214 | + # Truncate long values with ellipsis | |
| 215 | + display_value = ( | |
| 216 | + value if len(value) <= max_value else value[: max_value - 3] + "..." | |
| 217 | + ) | |
| 218 | + print( | |
| 219 | + f"{group:<{max_group}} {item_type:<{max_type}} {key:<{max_key}} {display_value:<{max_value}} {status:<{max_status}}" | |
| 220 | + ) | |
| 221 | + | |
| 222 | + def _print_summary(self, items, active_groups): | |
| 223 | + """Print summary information""" | |
| 224 | + print() | |
| 225 | + total_items = len(items) | |
| 226 | + active_items = len( | |
| 227 | + [item for item in items if item[4] in ["ACTIVE", "PERSISTENT"]] | |
| 228 | + ) | |
| 229 | + print(f"Total: {total_items} items ({active_items} active)") | |
| 230 | + | |
| 231 | + # Show available commands | |
| 232 | + print() | |
| 233 | + print("Use 'shtick list -l' for detailed view") | |
| 234 | + if any(item[4] == "inactive" for item in items): | |
| 235 | + inactive_groups = set(item[0] for item in items if item[4] == "inactive") | |
| 236 | + print(f"Activate groups with: shtick activate <group>") | |
| 237 | + print(f"Inactive groups: {', '.join(sorted(inactive_groups))}") | |
| 238 | + | |
| 239 | + def shells(self, long_format: bool = False): | |
| 240 | + """List supported shells""" | |
| 241 | + shells = sorted(get_supported_shells()) | |
| 242 | + | |
| 243 | + if long_format: | |
| 244 | + print("Supported shells:") | |
| 245 | + for shell in shells: | |
| 246 | + print(f" {shell}") | |
| 247 | + else: | |
| 248 | + self._print_shells_columns(shells) | |
| 249 | + | |
| 250 | + def _print_shells_columns(self, shells): | |
| 251 | + """Print shells in columns like ls output""" | |
| 252 | + if not shells: | |
| 253 | + print("No shells configured") | |
| 254 | + return | |
| 255 | + | |
| 256 | + # Try to get terminal width, fallback to 80 | |
| 257 | + try: | |
| 258 | + import shutil | |
| 259 | + | |
| 260 | + terminal_width = shutil.get_terminal_size().columns | |
| 261 | + except: | |
| 262 | + terminal_width = 80 | |
| 263 | + | |
| 264 | + # Find the longest shell name | |
| 265 | + max_shell_length = max(len(shell) for shell in shells) | |
| 266 | + column_width = max_shell_length + 2 | |
| 267 | + | |
| 268 | + # Calculate how many columns we can fit | |
| 269 | + columns = max(1, terminal_width // column_width) | |
| 270 | + rows = (len(shells) + columns - 1) // columns | |
| 271 | + | |
| 272 | + print(f"Supported shells ({len(shells)} total):") | |
| 273 | + print() | |
| 274 | + | |
| 275 | + # Print shells in columns | |
| 276 | + for row in range(rows): | |
| 277 | + line = "" | |
| 278 | + for col in range(columns): | |
| 279 | + index = row + col * rows | |
| 280 | + if index < len(shells): | |
| 281 | + shell = shells[index] | |
| 282 | + line += f"{shell:<{column_width}}" | |
| 283 | + print(line.rstrip()) | |