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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +7 -0
- data/bin/dam +16 -16
- data/bin/zsh_history.rb +2 -14
- data/docs/architecture/cli/cli-pattern-comparison.md +147 -17
- data/docs/architecture/cli/cli-patterns.md +311 -18
- data/docs/architecture/cli/pattern-4-delegated-cli.md +1058 -0
- data/lib/appydave/tools/dam/project_listing.rb +2 -12
- data/lib/appydave/tools/jump/cli.rb +0 -8
- data/lib/appydave/tools/jump/commands/generate.rb +0 -4
- data/lib/appydave/tools/jump/formatters/table_formatter.rb +0 -2
- data/lib/appydave/tools/jump/search.rb +0 -2
- data/lib/appydave/tools/version.rb +1 -1
- data/lib/appydave/tools.rb +3 -2
- data/package.json +1 -1
- metadata +3 -2
|
@@ -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
|