tenseleyflow/shtick / 63edf82

Browse files

refactor

Authored by espadonne
SHA
63edf82430248db97f82097f39ccf18e7a5b75e6
Parents
fb3e924
Tree
c7cf71f

3 changed files

StatusFile+-
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
 import sys
7
 import sys
8
 import argparse
8
 import argparse
9
-import os
9
+from shtick.commands import ShtickCommands
10
-from typing import Optional
10
+from shtick.display import DisplayCommands
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)
596
 
11
 
597
 
12
 
598
 def main():
13
 def main():
@@ -636,6 +51,24 @@ def main():
636
         "assignment", help="Assignment in format key=value"
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
     # Remove command
72
     # Remove command
640
     rm_parser = subparsers.add_parser("remove", help="Remove an item from config")
73
     rm_parser = subparsers.add_parser("remove", help="Remove an item from config")
641
     rm_parser.add_argument(
74
     rm_parser.add_argument(
@@ -682,32 +115,58 @@ def main():
682
         help="Show one shell per line instead of columns",
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
     args = parser.parse_args()
126
     args = parser.parse_args()
686
 
127
 
687
     if not args.command:
128
     if not args.command:
129
+        # Show helpful getting started message
688
         parser.print_help()
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
         sys.exit(1)
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
     if args.command == "generate":
142
     if args.command == "generate":
692
-        cmd_generate(args)
143
+        commands.generate(args.config, args.terse)
693
     elif args.command == "add":
144
     elif args.command == "add":
694
-        cmd_add(args)
145
+        commands.add_item(args.type, args.group, args.assignment)
695
     elif args.command == "add-persistent":
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
     elif args.command == "remove":
154
     elif args.command == "remove":
698
-        cmd_remove(args)
155
+        commands.remove_item(args.type, args.group, args.search)
699
     elif args.command == "remove-persistent":
156
     elif args.command == "remove-persistent":
700
-        cmd_remove_persistent(args)
157
+        commands.remove_item(args.type, "persistent", args.search)
701
     elif args.command == "activate":
158
     elif args.command == "activate":
702
-        cmd_activate(args)
159
+        commands.activate_group(args.group)
703
     elif args.command == "deactivate":
160
     elif args.command == "deactivate":
704
-        cmd_deactivate(args)
161
+        commands.deactivate_group(args.group)
705
     elif args.command == "status":
162
     elif args.command == "status":
706
-        cmd_status(args)
163
+        display.status()
707
     elif args.command == "list":
164
     elif args.command == "list":
708
-        cmd_list(args)
165
+        display.list_config(args.long)
709
     elif args.command == "shells":
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
 if __name__ == "__main__":
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())