appydave-tools 0.74.0 → 0.74.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1058 @@
1
+ # Pattern 4: Delegated CLI Class
2
+
3
+ Complete guide to the Delegated CLI Class pattern for professional-grade CLI tools with 10+ commands.
4
+
5
+ ## Table of Contents
6
+
7
+ - [Overview](#overview)
8
+ - [When to Use](#when-to-use)
9
+ - [Structure](#structure)
10
+ - [Implementation Guide](#implementation-guide)
11
+ - [Testing Approach](#testing-approach)
12
+ - [Pattern 3 vs Pattern 4](#pattern-3-vs-pattern-4)
13
+ - [Migration Guide](#migration-guide)
14
+ - [Real-World Example: Jump](#real-world-example-jump)
15
+
16
+ ---
17
+
18
+ ## Overview
19
+
20
+ **Pattern 4** separates CLI interface code into a dedicated class in `lib/`, making it fully testable and professional-grade.
21
+
22
+ ### Key Characteristics
23
+
24
+ - ✅ **Full CLI in lib/** - Complete CLI implementation as a testable class
25
+ - ✅ **Ultra-thin bin/** - Just 25-40 lines (creates CLI object, calls run)
26
+ - ✅ **Testable CLI** - Unit test CLI behavior, exit codes, error handling
27
+ - ✅ **Dependency injection** - Inject config, output, validators for testing
28
+ - ✅ **10+ commands** - Scales to complex tools
29
+ - ✅ **Exit codes** - Proper Unix exit code handling
30
+ - ✅ **Professional grade** - Production-ready CLI architecture
31
+
32
+ ### Architecture Diagram
33
+
34
+ ```
35
+ ┌─────────────────────────────────────────────────────────────────────┐
36
+ │ PATTERN 4: DELEGATED CLI CLASS │
37
+ ├─────────────────────────────────────────────────────────────────────┤
38
+ │ │
39
+ │ bin/tool.rb (30 lines) ──────────┐ │
40
+ │ #!/usr/bin/env ruby │ │
41
+ │ require 'appydave/tools' │ │
42
+ │ cli = Tools::Tool::CLI.new │ │
43
+ │ exit(cli.run(ARGV)) │ │
44
+ │ │ │
45
+ │ lib/tools/tool/ ◄────────────────┘ │
46
+ │ ├── cli.rb (Full CLI implementation) │
47
+ │ │ ├── def run(args) (Entry point) │
48
+ │ │ ├── def run_search (Command dispatcher) │
49
+ │ │ ├── def run_add (Command dispatcher) │
50
+ │ │ └── def run_remove (Command dispatcher) │
51
+ │ │ │
52
+ │ ├── search.rb (Business logic) │
53
+ │ ├── crud.rb (Business logic) │
54
+ │ └── config.rb (Configuration) │
55
+ │ │
56
+ │ spec/tools/tool/ │
57
+ │ ├── cli_spec.rb (Test CLI behavior!) │
58
+ │ ├── search_spec.rb │
59
+ │ └── crud_spec.rb │
60
+ │ │
61
+ │ Characteristics: │
62
+ │ • CLI is a class (testable via RSpec) │
63
+ │ • Dependency injection (config, output, validators) │
64
+ │ • Exit codes (0 success, 1-4 errors) │
65
+ │ • Case/when command dispatch │
66
+ │ │
67
+ │ Example: jump (10 commands, 400+ lines in lib/cli.rb) │
68
+ └─────────────────────────────────────────────────────────────────────┘
69
+ ```
70
+
71
+ ---
72
+
73
+ ## When to Use
74
+
75
+ ### ✅ Use Pattern 4 When:
76
+
77
+ | Criteria | Threshold | Why |
78
+ |----------|-----------|-----|
79
+ | **Number of commands** | 10+ | Case/when scales better than method hash |
80
+ | **CLI complexity** | 300+ lines | Justifies separate CLI class |
81
+ | **Exit codes needed** | Yes | Professional Unix exit code handling |
82
+ | **Need CLI tests** | Yes | Test CLI behavior separately from business logic |
83
+ | **Dependency injection** | Yes | Mock config/output for testing |
84
+ | **Professional tool** | Yes | Production-ready architecture |
85
+ | **Complex help system** | Yes | Hierarchical help (main, command, topic) |
86
+
87
+ ### ❌ Don't Use Pattern 4 When:
88
+
89
+ - Tool has < 10 commands → Use Pattern 2 or 3
90
+ - CLI logic is simple → Pattern 2 sufficient
91
+ - Don't need CLI testing → Pattern 2 or 3 sufficient
92
+ - Tool is internal/experimental → Simpler pattern OK
93
+
94
+ ### Pattern 4 vs Pattern 3
95
+
96
+ **Use Pattern 4 over Pattern 3 when:**
97
+ - You want to **test CLI behavior** (exit codes, output, error messages)
98
+ - CLI has **complex command routing** (10+ commands)
99
+ - Need **dependency injection** for testing
100
+ - Building a **professional-grade tool** (like `jump`, `git`, `docker`)
101
+
102
+ **Use Pattern 3 over Pattern 4 when:**
103
+ - Commands **share validation patterns** (BaseAction enforces consistency)
104
+ - CLI is **simple enough** to live in bin/
105
+ - **Template method pattern** is beneficial
106
+ - **6-9 commands** (not quite 10+)
107
+
108
+ ---
109
+
110
+ ## Structure
111
+
112
+ ### Directory Layout
113
+
114
+ ```
115
+ appydave-tools/
116
+ ├── bin/
117
+ │ └── tool.rb # Thin wrapper (30 lines)
118
+
119
+ ├── lib/appydave/tools/
120
+ │ └── tool/
121
+ │ ├── cli.rb # Full CLI class (400+ lines)
122
+ │ ├── config.rb # Configuration
123
+ │ ├── search.rb # Business logic
124
+ │ ├── crud.rb # Business logic
125
+ │ ├── formatters/ # Output formatting
126
+ │ │ ├── table_formatter.rb
127
+ │ │ ├── json_formatter.rb
128
+ │ │ └── paths_formatter.rb
129
+ │ └── _doc.md # Documentation
130
+
131
+ └── spec/appydave/tools/
132
+ └── tool/
133
+ ├── cli_spec.rb # Test CLI behavior!
134
+ ├── search_spec.rb
135
+ └── crud_spec.rb
136
+ ```
137
+
138
+ ### File Sizes
139
+
140
+ | File | Lines | Purpose |
141
+ |------|-------|---------|
142
+ | `bin/tool.rb` | 25-40 | Wrapper only |
143
+ | `lib/tool/cli.rb` | 300-800 | Full CLI implementation |
144
+ | `lib/tool/business.rb` | 50-300 | Business logic per module |
145
+ | `spec/tool/cli_spec.rb` | 100-300 | CLI tests |
146
+
147
+ ---
148
+
149
+ ## Implementation Guide
150
+
151
+ ### Step 1: Create bin/ Wrapper
152
+
153
+ **bin/tool.rb** (30 lines max):
154
+
155
+ ```ruby
156
+ #!/usr/bin/env ruby
157
+ # frozen_string_literal: true
158
+
159
+ # Tool Description
160
+ #
161
+ # Usage:
162
+ # tool search <terms> # Fuzzy search
163
+ # tool get <key> # Get by key
164
+ # tool list # List all
165
+ # tool add --key <key> # Add new
166
+ # tool remove <key> --force # Remove
167
+ # tool help # Show help
168
+ #
169
+ # Examples:
170
+ # tool search my project
171
+ # tool add --key my-proj --path ~/dev/my-proj
172
+
173
+ $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
174
+
175
+ require 'appydave/tools'
176
+
177
+ cli = Appydave::Tools::Tool::CLI.new
178
+ exit_code = cli.run(ARGV)
179
+ exit(exit_code)
180
+ ```
181
+
182
+ **Key points:**
183
+ - ✅ Usage documentation in comments
184
+ - ✅ Examples in comments
185
+ - ✅ Loads lib/ path
186
+ - ✅ Creates CLI instance
187
+ - ✅ Passes ARGV to run()
188
+ - ✅ Exits with proper code
189
+
190
+ ### Step 2: Create CLI Class
191
+
192
+ **lib/appydave/tools/tool/cli.rb** (full implementation):
193
+
194
+ ```ruby
195
+ # frozen_string_literal: true
196
+
197
+ module Appydave
198
+ module Tools
199
+ module Tool
200
+ # CLI provides the command-line interface for the Tool
201
+ #
202
+ # Uses the Delegated CLI pattern for 10+ commands with
203
+ # dependency injection and comprehensive testing.
204
+ #
205
+ # @example Usage
206
+ # cli = CLI.new
207
+ # cli.run(['search', 'term'])
208
+ # cli.run(['add', '--key', 'my-project', '--path', '~/dev/project'])
209
+ class CLI
210
+ # Exit codes following Unix conventions
211
+ EXIT_SUCCESS = 0
212
+ EXIT_NOT_FOUND = 1
213
+ EXIT_INVALID_INPUT = 2
214
+ EXIT_CONFIG_ERROR = 3
215
+ EXIT_PATH_NOT_FOUND = 4
216
+
217
+ attr_reader :config, :validator, :output
218
+
219
+ # Initialize CLI with optional dependencies (for testing)
220
+ #
221
+ # @param config [Config] Configuration object (default: loads from file)
222
+ # @param validator [PathValidator] Path validator (default: new instance)
223
+ # @param output [IO] Output stream (default: $stdout)
224
+ def initialize(config: nil, validator: nil, output: $stdout)
225
+ @validator = validator || PathValidator.new
226
+ @output = output
227
+ @config = config
228
+ end
229
+
230
+ # Main entry point - dispatches to command methods
231
+ #
232
+ # @param args [Array<String>] Command-line arguments
233
+ # @return [Integer] Exit code
234
+ def run(args = ARGV)
235
+ command = args.shift
236
+
237
+ case command
238
+ when nil, '', '--help', '-h'
239
+ show_main_help
240
+ EXIT_SUCCESS
241
+ when '--version', '-v'
242
+ show_version
243
+ EXIT_SUCCESS
244
+ when 'help'
245
+ show_help(args)
246
+ EXIT_SUCCESS
247
+ when 'search'
248
+ run_search(args)
249
+ when 'get'
250
+ run_get(args)
251
+ when 'list'
252
+ run_list(args)
253
+ when 'add'
254
+ run_add(args)
255
+ when 'update'
256
+ run_update(args)
257
+ when 'remove'
258
+ run_remove(args)
259
+ when 'validate'
260
+ run_validate(args)
261
+ when 'report'
262
+ run_report(args)
263
+ when 'generate'
264
+ run_generate(args)
265
+ when 'info'
266
+ run_info(args)
267
+ else
268
+ output.puts "Unknown command: #{command}"
269
+ output.puts "Run 'tool help' for available commands."
270
+ EXIT_INVALID_INPUT
271
+ end
272
+ rescue StandardError => e
273
+ output.puts "Error: #{e.message}"
274
+ output.puts e.backtrace.first(3).join("\n") if ENV['DEBUG']
275
+ EXIT_CONFIG_ERROR
276
+ end
277
+
278
+ private
279
+
280
+ # Lazy-load configuration
281
+ def load_config
282
+ @config ||= Config.new
283
+ end
284
+
285
+ # Extract format option from args
286
+ def format_option(args)
287
+ format_index = args.index('--format') || args.index('-f')
288
+ return 'table' unless format_index
289
+
290
+ format = args[format_index + 1]
291
+ args.delete_at(format_index + 1)
292
+ args.delete_at(format_index)
293
+ format || 'table'
294
+ end
295
+
296
+ # Format output using appropriate formatter
297
+ def format_output(result, format)
298
+ formatter = case format
299
+ when 'json'
300
+ Formatters::JsonFormatter.new(result)
301
+ when 'paths'
302
+ Formatters::PathsFormatter.new(result)
303
+ else
304
+ Formatters::TableFormatter.new(result)
305
+ end
306
+
307
+ output.puts formatter.format
308
+ end
309
+
310
+ # Convert result hash to exit code
311
+ def exit_code_for(result)
312
+ return EXIT_SUCCESS if result[:success]
313
+
314
+ case result[:code]
315
+ when 'NOT_FOUND'
316
+ EXIT_NOT_FOUND
317
+ when 'INVALID_INPUT', 'DUPLICATE_KEY', 'CONFIRMATION_REQUIRED'
318
+ EXIT_INVALID_INPUT
319
+ when 'PATH_NOT_FOUND'
320
+ EXIT_PATH_NOT_FOUND
321
+ else
322
+ EXIT_CONFIG_ERROR
323
+ end
324
+ end
325
+
326
+ # Command implementations
327
+
328
+ def run_search(args)
329
+ format = format_option(args)
330
+ query = args.join(' ')
331
+
332
+ search = Search.new(load_config)
333
+ result = search.search(query)
334
+
335
+ format_output(result, format)
336
+ exit_code_for(result)
337
+ end
338
+
339
+ def run_get(args)
340
+ format = format_option(args)
341
+ key = args.first
342
+
343
+ unless key
344
+ output.puts 'Usage: tool get <key>'
345
+ return EXIT_INVALID_INPUT
346
+ end
347
+
348
+ search = Search.new(load_config)
349
+ result = search.get(key)
350
+
351
+ format_output(result, format)
352
+ exit_code_for(result)
353
+ end
354
+
355
+ def run_list(args)
356
+ format = format_option(args)
357
+
358
+ search = Search.new(load_config)
359
+ result = search.list
360
+
361
+ format_output(result, format)
362
+ exit_code_for(result)
363
+ end
364
+
365
+ def run_add(args)
366
+ # Parse options with OptionParser
367
+ options = {}
368
+ OptionParser.new do |opts|
369
+ opts.banner = 'Usage: tool add [options]'
370
+ opts.on('--key KEY', 'Unique identifier') { |v| options[:key] = v }
371
+ opts.on('--path PATH', 'Folder path') { |v| options[:path] = v }
372
+ opts.on('--brand BRAND', 'Brand name') { |v| options[:brand] = v }
373
+ opts.on('-h', '--help', 'Show this message') do
374
+ output.puts opts
375
+ return EXIT_SUCCESS
376
+ end
377
+ end.parse!(args)
378
+
379
+ # Validate required options
380
+ unless options[:key] && options[:path]
381
+ output.puts 'Missing required options: --key and --path'
382
+ return EXIT_INVALID_INPUT
383
+ end
384
+
385
+ crud = Crud.new(load_config)
386
+ result = crud.add(options)
387
+
388
+ if result[:success]
389
+ output.puts "✅ Added: #{options[:key]}"
390
+ EXIT_SUCCESS
391
+ else
392
+ output.puts "❌ Error: #{result[:message]}"
393
+ exit_code_for(result)
394
+ end
395
+ end
396
+
397
+ # ... more command methods ...
398
+
399
+ # Help system
400
+
401
+ def show_main_help
402
+ output.puts 'Tool - Description'
403
+ output.puts ''
404
+ output.puts 'Usage: tool [command] [options]'
405
+ output.puts ''
406
+ output.puts 'Commands:'
407
+ output.puts ' search <terms> Search locations by fuzzy matching'
408
+ output.puts ' get <key> Get location by exact key'
409
+ output.puts ' list List all locations'
410
+ output.puts ' add [options] Add new location'
411
+ output.puts ' update <key> Update existing location'
412
+ output.puts ' remove <key> Remove location'
413
+ output.puts ' validate [key] Validate paths exist'
414
+ output.puts ' report <type> Generate reports'
415
+ output.puts ' generate <target> Generate aliases/help'
416
+ output.puts ' info Show configuration info'
417
+ output.puts ''
418
+ output.puts "Run 'tool help <command>' for more information on a command."
419
+ end
420
+
421
+ def show_version
422
+ output.puts "Tool v#{Appydave::Tools::VERSION}"
423
+ output.puts 'Part of appydave-tools gem'
424
+ end
425
+
426
+ def show_help(args)
427
+ topic = args.first
428
+
429
+ case topic
430
+ when 'search'
431
+ show_search_help
432
+ when 'add'
433
+ show_add_help
434
+ # ... more help topics ...
435
+ else
436
+ show_main_help
437
+ end
438
+ end
439
+
440
+ def show_search_help
441
+ output.puts 'Usage: tool search <terms>'
442
+ output.puts ''
443
+ output.puts 'Search for locations using fuzzy matching.'
444
+ output.puts ''
445
+ output.puts 'Examples:'
446
+ output.puts ' tool search my project'
447
+ output.puts ' tool search appydave ruby'
448
+ end
449
+ end
450
+ end
451
+ end
452
+ end
453
+ ```
454
+
455
+ ### Step 3: Business Logic
456
+
457
+ **lib/appydave/tools/tool/search.rb**:
458
+
459
+ ```ruby
460
+ # frozen_string_literal: true
461
+
462
+ module Appydave
463
+ module Tools
464
+ module Tool
465
+ # Search provides fuzzy search and exact lookup
466
+ class Search
467
+ def initialize(config)
468
+ @config = config
469
+ end
470
+
471
+ def search(query)
472
+ # Fuzzy search implementation
473
+ locations = @config.locations
474
+ matches = locations.select do |loc|
475
+ match_query?(loc, query)
476
+ end
477
+
478
+ {
479
+ success: !matches.empty?,
480
+ code: matches.empty? ? 'NOT_FOUND' : nil,
481
+ message: matches.empty? ? 'No matches found' : nil,
482
+ data: matches
483
+ }
484
+ end
485
+
486
+ def get(key)
487
+ # Exact key lookup
488
+ location = @config.find_location(key)
489
+
490
+ {
491
+ success: !location.nil?,
492
+ code: location.nil? ? 'NOT_FOUND' : nil,
493
+ message: location.nil? ? "Location not found: #{key}" : nil,
494
+ data: location
495
+ }
496
+ end
497
+
498
+ def list
499
+ # List all locations
500
+ {
501
+ success: true,
502
+ data: @config.locations
503
+ }
504
+ end
505
+
506
+ private
507
+
508
+ def match_query?(location, query)
509
+ # Fuzzy matching logic
510
+ end
511
+ end
512
+ end
513
+ end
514
+ end
515
+ ```
516
+
517
+ ---
518
+
519
+ ## Testing Approach
520
+
521
+ ### Testing the CLI Class
522
+
523
+ **spec/appydave/tools/tool/cli_spec.rb**:
524
+
525
+ ```ruby
526
+ # frozen_string_literal: true
527
+
528
+ RSpec.describe Appydave::Tools::Tool::CLI do
529
+ subject(:cli) { described_class.new(config: mock_config, output: output) }
530
+
531
+ let(:output) { StringIO.new }
532
+ let(:mock_config) do
533
+ instance_double(
534
+ Appydave::Tools::Tool::Config,
535
+ locations: mock_locations,
536
+ find_location: mock_location
537
+ )
538
+ end
539
+ let(:mock_locations) do
540
+ [
541
+ { key: 'proj-1', path: '/path/to/proj-1', brand: 'appydave' },
542
+ { key: 'proj-2', path: '/path/to/proj-2', brand: 'voz' }
543
+ ]
544
+ end
545
+ let(:mock_location) { mock_locations.first }
546
+
547
+ describe '#run' do
548
+ context 'with no arguments' do
549
+ it 'shows main help and returns success' do
550
+ exit_code = cli.run([])
551
+
552
+ expect(output.string).to include('Usage: tool [command]')
553
+ expect(exit_code).to eq(described_class::EXIT_SUCCESS)
554
+ end
555
+ end
556
+
557
+ context 'with --help flag' do
558
+ it 'shows main help and returns success' do
559
+ exit_code = cli.run(['--help'])
560
+
561
+ expect(output.string).to include('Usage: tool [command]')
562
+ expect(exit_code).to eq(described_class::EXIT_SUCCESS)
563
+ end
564
+ end
565
+
566
+ context 'with --version flag' do
567
+ it 'shows version and returns success' do
568
+ exit_code = cli.run(['--version'])
569
+
570
+ expect(output.string).to include('Tool v')
571
+ expect(exit_code).to eq(described_class::EXIT_SUCCESS)
572
+ end
573
+ end
574
+
575
+ context 'with unknown command' do
576
+ it 'shows error and returns invalid input code' do
577
+ exit_code = cli.run(['unknown'])
578
+
579
+ expect(output.string).to include('Unknown command: unknown')
580
+ expect(exit_code).to eq(described_class::EXIT_INVALID_INPUT)
581
+ end
582
+ end
583
+ end
584
+
585
+ describe 'search command' do
586
+ it 'searches for locations and returns success' do
587
+ exit_code = cli.run(['search', 'proj', '1'])
588
+
589
+ expect(output.string).to include('proj-1')
590
+ expect(exit_code).to eq(described_class::EXIT_SUCCESS)
591
+ end
592
+
593
+ it 'returns not found code when no matches' do
594
+ exit_code = cli.run(['search', 'nonexistent'])
595
+
596
+ expect(output.string).to include('No matches found')
597
+ expect(exit_code).to eq(described_class::EXIT_NOT_FOUND)
598
+ end
599
+ end
600
+
601
+ describe 'get command' do
602
+ it 'gets location by key and returns success' do
603
+ exit_code = cli.run(['get', 'proj-1'])
604
+
605
+ expect(output.string).to include('proj-1')
606
+ expect(exit_code).to eq(described_class::EXIT_SUCCESS)
607
+ end
608
+
609
+ it 'shows usage when key not provided' do
610
+ exit_code = cli.run(['get'])
611
+
612
+ expect(output.string).to include('Usage: tool get <key>')
613
+ expect(exit_code).to eq(described_class::EXIT_INVALID_INPUT)
614
+ end
615
+ end
616
+
617
+ describe 'list command' do
618
+ it 'lists all locations and returns success' do
619
+ exit_code = cli.run(['list'])
620
+
621
+ expect(output.string).to include('proj-1')
622
+ expect(output.string).to include('proj-2')
623
+ expect(exit_code).to eq(described_class::EXIT_SUCCESS)
624
+ end
625
+ end
626
+
627
+ describe 'add command' do
628
+ let(:mock_crud) { instance_double(Appydave::Tools::Tool::Crud) }
629
+
630
+ before do
631
+ allow(Appydave::Tools::Tool::Crud).to receive(:new).and_return(mock_crud)
632
+ end
633
+
634
+ it 'adds location and returns success' do
635
+ allow(mock_crud).to receive(:add).and_return({ success: true })
636
+
637
+ exit_code = cli.run(['add', '--key', 'new-proj', '--path', '/path/to/new'])
638
+
639
+ expect(output.string).to include('Added: new-proj')
640
+ expect(exit_code).to eq(described_class::EXIT_SUCCESS)
641
+ end
642
+
643
+ it 'shows error when key missing' do
644
+ exit_code = cli.run(['add', '--path', '/path/to/new'])
645
+
646
+ expect(output.string).to include('Missing required options')
647
+ expect(exit_code).to eq(described_class::EXIT_INVALID_INPUT)
648
+ end
649
+
650
+ it 'returns error code on duplicate key' do
651
+ allow(mock_crud).to receive(:add).and_return({
652
+ success: false,
653
+ code: 'DUPLICATE_KEY',
654
+ message: 'Key already exists'
655
+ })
656
+
657
+ exit_code = cli.run(['add', '--key', 'proj-1', '--path', '/path'])
658
+
659
+ expect(output.string).to include('Error: Key already exists')
660
+ expect(exit_code).to eq(described_class::EXIT_INVALID_INPUT)
661
+ end
662
+ end
663
+
664
+ describe 'error handling' do
665
+ it 'catches exceptions and returns config error code' do
666
+ allow(mock_config).to receive(:locations).and_raise(StandardError, 'Config error')
667
+
668
+ exit_code = cli.run(['list'])
669
+
670
+ expect(output.string).to include('Error: Config error')
671
+ expect(exit_code).to eq(described_class::EXIT_CONFIG_ERROR)
672
+ end
673
+ end
674
+
675
+ describe 'output formatting' do
676
+ it 'supports table format (default)' do
677
+ exit_code = cli.run(['list'])
678
+
679
+ expect(output.string).to match(/proj-1.*proj-2/m)
680
+ expect(exit_code).to eq(described_class::EXIT_SUCCESS)
681
+ end
682
+
683
+ it 'supports JSON format' do
684
+ exit_code = cli.run(['list', '--format', 'json'])
685
+
686
+ json_output = JSON.parse(output.string)
687
+ expect(json_output).to be_an(Array)
688
+ expect(exit_code).to eq(described_class::EXIT_SUCCESS)
689
+ end
690
+
691
+ it 'supports paths format' do
692
+ exit_code = cli.run(['list', '--format', 'paths'])
693
+
694
+ expect(output.string).to include('/path/to/proj-1')
695
+ expect(output.string).to include('/path/to/proj-2')
696
+ expect(exit_code).to eq(described_class::EXIT_SUCCESS)
697
+ end
698
+ end
699
+ end
700
+ ```
701
+
702
+ **Key testing benefits:**
703
+ - ✅ Test exit codes (success, errors)
704
+ - ✅ Test output messages
705
+ - ✅ Test error handling
706
+ - ✅ Mock dependencies (config, validators)
707
+ - ✅ Test all commands independently
708
+
709
+ ---
710
+
711
+ ## Pattern 3 vs Pattern 4
712
+
713
+ ### Side-by-Side Comparison
714
+
715
+ | Aspect | Pattern 3 (BaseAction) | Pattern 4 (Delegated CLI) |
716
+ |--------|------------------------|---------------------------|
717
+ | **CLI location** | bin/ (not testable) | lib/ (fully testable) |
718
+ | **Command dispatch** | Method hash (`'cmd' => Action.new`) | Case/when (`when 'cmd'`) |
719
+ | **Shared validation** | ✅ Template method in BaseAction | ⚠️ Manual (extract to modules) |
720
+ | **Exit codes** | ⚠️ Manual implementation | ✅ Built-in constants |
721
+ | **Dependency injection** | ❌ No | ✅ Yes (config, output, validators) |
722
+ | **CLI testing** | ❌ Hard (bin/ scripts) | ✅ Easy (RSpec with mocks) |
723
+ | **Scalability** | ✅ Good (6-20 commands) | ✅ Excellent (10+ commands) |
724
+ | **Consistency** | ✅ Enforced by BaseAction | ⚠️ Manual (conventions) |
725
+ | **Best for** | API-style commands | Complex CLI tools |
726
+ | **Example** | youtube_manager | jump |
727
+ | **Bin/ size** | ~80 lines | ~30 lines |
728
+ | **Lib/ size** | Actions + business logic | CLI + business logic |
729
+
730
+ ### When to Choose Pattern 4 Over Pattern 3
731
+
732
+ **Choose Pattern 4 if:**
733
+ 1. ✅ You want to **test CLI behavior** (not just business logic)
734
+ 2. ✅ Tool has **10+ commands** (case/when scales better)
735
+ 3. ✅ Need **professional exit codes** (Unix conventions)
736
+ 4. ✅ Want **dependency injection** for testing
737
+ 5. ✅ Building a **production tool** (not internal script)
738
+ 6. ✅ CLI complexity > 300 lines
739
+
740
+ **Choose Pattern 3 if:**
741
+ 1. ✅ Commands **share validation patterns** (BaseAction enforces)
742
+ 2. ✅ **6-9 commands** (not quite 10+)
743
+ 3. ✅ Don't need CLI testing (business logic tests sufficient)
744
+ 4. ✅ **Template method** is beneficial
745
+ 5. ✅ Team prefers **OOP patterns** (inheritance)
746
+
747
+ ---
748
+
749
+ ## Migration Guide
750
+
751
+ ### Migrating from Pattern 2 to Pattern 4
752
+
753
+ **Example: DAM tool (1,603 lines in bin/)**
754
+
755
+ #### Before (Pattern 2)
756
+
757
+ ```
758
+ bin/dam (1,603 lines)
759
+ ├── class VatCLI
760
+ ├── def run
761
+ ├── def list_command (100 lines)
762
+ ├── def s3_up_command (150 lines)
763
+ ├── def s3_down_command (150 lines)
764
+ └── ... 15 more command methods
765
+ ```
766
+
767
+ #### After (Pattern 4)
768
+
769
+ ```
770
+ bin/dam (30 lines)
771
+ ├── require 'appydave/tools'
772
+ ├── cli = Appydave::Tools::Dam::CLI.new
773
+ └── exit(cli.run(ARGV))
774
+
775
+ lib/appydave/tools/dam/
776
+ ├── cli.rb (600 lines - testable!)
777
+ │ ├── class CLI
778
+ │ ├── def run(args)
779
+ │ ├── def run_list
780
+ │ ├── def run_s3_up
781
+ │ └── ... command methods
782
+ ├── s3_operations.rb (300 lines)
783
+ ├── ssd_operations.rb (200 lines)
784
+ └── config.rb (100 lines)
785
+
786
+ spec/appydave/tools/dam/
787
+ ├── cli_spec.rb (NEW - test CLI!)
788
+ ├── s3_operations_spec.rb
789
+ └── ssd_operations_spec.rb
790
+ ```
791
+
792
+ #### Migration Steps
793
+
794
+ 1. **Create lib/tool/cli.rb**
795
+ ```bash
796
+ mkdir -p lib/appydave/tools/dam
797
+ touch lib/appydave/tools/dam/cli.rb
798
+ ```
799
+
800
+ 2. **Move CLI class from bin/ to lib/**
801
+ - Copy `VatCLI` class to `lib/appydave/tools/dam/cli.rb`
802
+ - Rename class to `CLI`
803
+ - Wrap in module: `Appydave::Tools::Dam::CLI`
804
+
805
+ 3. **Add dependency injection**
806
+ ```ruby
807
+ def initialize(config: nil, output: $stdout)
808
+ @output = output
809
+ @config = config
810
+ end
811
+ ```
812
+
813
+ 4. **Convert method hash to case/when**
814
+ ```ruby
815
+ # Before (Pattern 2)
816
+ @commands = {
817
+ 'list' => method(:list_command),
818
+ 's3-up' => method(:s3_up_command)
819
+ }
820
+
821
+ # After (Pattern 4)
822
+ case command
823
+ when 'list'
824
+ run_list(args)
825
+ when 's3-up'
826
+ run_s3_up(args)
827
+ end
828
+ ```
829
+
830
+ 5. **Add exit codes**
831
+ ```ruby
832
+ EXIT_SUCCESS = 0
833
+ EXIT_NOT_FOUND = 1
834
+ EXIT_INVALID_INPUT = 2
835
+ EXIT_CONFIG_ERROR = 3
836
+
837
+ def run(args)
838
+ case command
839
+ when 'list'
840
+ run_list(args)
841
+ else
842
+ output.puts "Unknown command"
843
+ EXIT_INVALID_INPUT
844
+ end
845
+ end
846
+ ```
847
+
848
+ 6. **Replace bin/ with thin wrapper**
849
+ ```ruby
850
+ #!/usr/bin/env ruby
851
+ # frozen_string_literal: true
852
+
853
+ $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
854
+ require 'appydave/tools'
855
+
856
+ cli = Appydave::Tools::Dam::CLI.new
857
+ exit_code = cli.run(ARGV)
858
+ exit(exit_code)
859
+ ```
860
+
861
+ 7. **Create CLI tests**
862
+ ```bash
863
+ touch spec/appydave/tools/dam/cli_spec.rb
864
+ ```
865
+
866
+ 8. **Test migration**
867
+ ```bash
868
+ # Run CLI tests
869
+ rspec spec/appydave/tools/dam/cli_spec.rb
870
+
871
+ # Test actual CLI
872
+ bin/dam list
873
+ bin/dam --version
874
+ ```
875
+
876
+ ---
877
+
878
+ ## Real-World Example: Jump
879
+
880
+ The **Jump tool** is the reference implementation of Pattern 4 in appydave-tools.
881
+
882
+ ### Stats
883
+
884
+ | Metric | Value |
885
+ |--------|-------|
886
+ | Commands | 10 |
887
+ | bin/jump.rb | 29 lines |
888
+ | lib/jump/cli.rb | 400+ lines |
889
+ | Exit codes | 5 (0-4) |
890
+ | Formatters | 3 (table, json, paths) |
891
+ | Help topics | 8+ |
892
+
893
+ ### Architecture
894
+
895
+ **bin/jump.rb** (29 lines):
896
+ ```ruby
897
+ #!/usr/bin/env ruby
898
+ # frozen_string_literal: true
899
+
900
+ # Jump Location Tool - Manage development folder locations
901
+ #
902
+ # Usage:
903
+ # jump search <terms> # Fuzzy search locations
904
+ # jump get <key> # Get by exact key
905
+ # jump list # List all locations
906
+ # jump add --key <key> --path <path> # Add new location
907
+ #
908
+ # Examples:
909
+ # jump search appydave ruby
910
+ # jump add --key my-proj --path ~/dev/my-proj --brand appydave
911
+
912
+ $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
913
+
914
+ require 'appydave/tools'
915
+
916
+ cli = Appydave::Tools::Jump::CLI.new
917
+ exit_code = cli.run(ARGV)
918
+ exit(exit_code)
919
+ ```
920
+
921
+ **lib/appydave/tools/jump/cli.rb** (excerpt):
922
+
923
+ ```ruby
924
+ module Appydave
925
+ module Tools
926
+ module Jump
927
+ # CLI provides the command-line interface for the Jump tool
928
+ class CLI
929
+ EXIT_SUCCESS = 0
930
+ EXIT_NOT_FOUND = 1
931
+ EXIT_INVALID_INPUT = 2
932
+ EXIT_CONFIG_ERROR = 3
933
+ EXIT_PATH_NOT_FOUND = 4
934
+
935
+ attr_reader :config, :path_validator, :output
936
+
937
+ def initialize(config: nil, path_validator: nil, output: $stdout)
938
+ @path_validator = path_validator || PathValidator.new
939
+ @output = output
940
+ @config = config
941
+ end
942
+
943
+ def run(args = ARGV)
944
+ command = args.shift
945
+
946
+ case command
947
+ when nil, '', '--help', '-h'
948
+ show_main_help
949
+ EXIT_SUCCESS
950
+ when '--version', '-v'
951
+ show_version
952
+ EXIT_SUCCESS
953
+ when 'help'
954
+ show_help(args)
955
+ EXIT_SUCCESS
956
+ when 'search'
957
+ run_search(args)
958
+ when 'get'
959
+ run_get(args)
960
+ when 'list'
961
+ run_list(args)
962
+ when 'add'
963
+ run_add(args)
964
+ when 'update'
965
+ run_update(args)
966
+ when 'remove'
967
+ run_remove(args)
968
+ when 'validate'
969
+ run_validate(args)
970
+ when 'report'
971
+ run_report(args)
972
+ when 'generate'
973
+ run_generate(args)
974
+ when 'info'
975
+ run_info(args)
976
+ else
977
+ output.puts "Unknown command: #{command}"
978
+ EXIT_INVALID_INPUT
979
+ end
980
+ rescue StandardError => e
981
+ output.puts "Error: #{e.message}"
982
+ EXIT_CONFIG_ERROR
983
+ end
984
+
985
+ # ... command implementations ...
986
+ end
987
+ end
988
+ end
989
+ end
990
+ ```
991
+
992
+ ### Why Jump Uses Pattern 4
993
+
994
+ 1. ✅ **10 commands** - Scales well with case/when
995
+ 2. ✅ **Complex CLI** - 400+ lines would bloat bin/
996
+ 3. ✅ **Testable** - Full CLI test coverage via RSpec
997
+ 4. ✅ **Exit codes** - Professional Unix exit codes
998
+ 5. ✅ **Dependency injection** - Mock config/validators in tests
999
+ 6. ✅ **Professional tool** - Production-grade development tool
1000
+
1001
+ ### File Structure
1002
+
1003
+ ```
1004
+ lib/appydave/tools/jump/
1005
+ ├── cli.rb # CLI class (400+ lines)
1006
+ ├── config.rb # Configuration
1007
+ ├── search.rb # Search/query logic
1008
+ ├── crud.rb # Add/update/remove
1009
+ ├── validators/
1010
+ │ └── path_validator.rb # Path validation
1011
+ ├── formatters/
1012
+ │ ├── table_formatter.rb # Table output
1013
+ │ ├── json_formatter.rb # JSON output
1014
+ │ └── paths_formatter.rb # Paths-only output
1015
+ └── generators/
1016
+ └── aliases_generator.rb # Generate shell aliases
1017
+
1018
+ spec/appydave/tools/jump/
1019
+ ├── cli_spec.rb # CLI tests (exit codes, output)
1020
+ ├── search_spec.rb # Search logic tests
1021
+ ├── crud_spec.rb # CRUD tests
1022
+ └── ... more tests
1023
+ ```
1024
+
1025
+ ---
1026
+
1027
+ ## Summary
1028
+
1029
+ **Pattern 4: Delegated CLI Class** is the professional-grade CLI pattern for:
1030
+
1031
+ ✅ **10+ commands** - Case/when dispatch scales well
1032
+ ✅ **300+ lines of CLI logic** - Justifies separate class
1033
+ ✅ **Testable CLI** - Full RSpec coverage of CLI behavior
1034
+ ✅ **Exit codes** - Professional Unix conventions
1035
+ ✅ **Dependency injection** - Mock dependencies for testing
1036
+ ✅ **Production tools** - Professional-grade architecture
1037
+
1038
+ **Key files:**
1039
+ - `bin/tool.rb` (30 lines) - Thin wrapper
1040
+ - `lib/tool/cli.rb` (400+ lines) - Full CLI implementation
1041
+ - `spec/tool/cli_spec.rb` (100+ lines) - CLI tests
1042
+
1043
+ **Reference implementation:** Jump tool (`bin/jump.rb` → `lib/appydave/tools/jump/cli.rb`)
1044
+
1045
+ **When to use:**
1046
+ - Building a professional CLI tool
1047
+ - Need to test CLI behavior
1048
+ - 10+ commands
1049
+ - Complex error handling
1050
+
1051
+ **When not to use:**
1052
+ - Simple tools (< 10 commands) → Use Pattern 2
1053
+ - Don't need CLI testing → Use Pattern 2 or 3
1054
+ - Commands share validation → Consider Pattern 3
1055
+
1056
+ ---
1057
+
1058
+ **Last updated:** 2025-02-04