appydave-tools 0.16.0 → 0.17.0

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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +6 -0
  3. data/AGENTS.md +22 -0
  4. data/CHANGELOG.md +12 -0
  5. data/CLAUDE.md +206 -51
  6. data/README.md +144 -11
  7. data/bin/archive_project.rb +249 -0
  8. data/bin/configuration.rb +21 -1
  9. data/bin/generate_manifest.rb +357 -0
  10. data/bin/sync_from_ssd.rb +236 -0
  11. data/bin/vat +623 -0
  12. data/docs/README.md +169 -0
  13. data/docs/configuration/.env.example +19 -0
  14. data/docs/configuration/README.md +394 -0
  15. data/docs/configuration/channels.example.json +26 -0
  16. data/docs/configuration/settings.example.json +6 -0
  17. data/docs/development/CODEX-recommendations.md +123 -0
  18. data/docs/development/README.md +100 -0
  19. data/docs/development/cli-architecture-patterns.md +1604 -0
  20. data/docs/development/pattern-comparison.md +284 -0
  21. data/docs/prd-unified-brands-configuration.md +792 -0
  22. data/docs/project-brand-systems-analysis.md +934 -0
  23. data/docs/vat/dam-vision.md +123 -0
  24. data/docs/vat/session-summary-2025-11-09.md +297 -0
  25. data/docs/vat/usage.md +508 -0
  26. data/docs/vat/vat-testing-plan.md +801 -0
  27. data/lib/appydave/tools/configuration/models/brands_config.rb +238 -0
  28. data/lib/appydave/tools/configuration/models/config_base.rb +7 -0
  29. data/lib/appydave/tools/configuration/models/settings_config.rb +4 -0
  30. data/lib/appydave/tools/vat/config.rb +153 -0
  31. data/lib/appydave/tools/vat/config_loader.rb +91 -0
  32. data/lib/appydave/tools/vat/manifest_generator.rb +239 -0
  33. data/lib/appydave/tools/vat/project_listing.rb +198 -0
  34. data/lib/appydave/tools/vat/project_resolver.rb +132 -0
  35. data/lib/appydave/tools/vat/s3_operations.rb +560 -0
  36. data/lib/appydave/tools/version.rb +1 -1
  37. data/lib/appydave/tools.rb +9 -1
  38. data/package.json +1 -1
  39. metadata +57 -3
  40. data/docs/dam/overview.md +0 -28
@@ -0,0 +1,1604 @@
1
+ # CLI Architecture Patterns
2
+
3
+ This guide documents the established CLI architecture patterns used in appydave-tools, providing developers with clear guidance on how to structure new tools and integrate them following existing conventions.
4
+
5
+ ## Table of Contents
6
+
7
+ - [Overview](#overview)
8
+ - [Philosophy](#philosophy)
9
+ - [The Three Patterns](#the-three-patterns)
10
+ - [Pattern 1: Single-Command Tools](#pattern-1-single-command-tools)
11
+ - [Pattern 2: Multi-Command with Inline Routing](#pattern-2-multi-command-with-inline-routing)
12
+ - [Pattern 3: Multi-Command with BaseAction](#pattern-3-multi-command-with-baseaction)
13
+ - [Decision Tree](#decision-tree)
14
+ - [Directory Structure](#directory-structure)
15
+ - [Best Practices](#best-practices)
16
+ - [Testing Approach](#testing-approach)
17
+ - [Migration Guide](#migration-guide)
18
+ - [Examples](#examples)
19
+
20
+ ---
21
+
22
+ ## Overview
23
+
24
+ AppyDave Tools follows a **consolidated toolkit philosophy** - multiple independent tools in one repository for easier maintenance than separate codebases. Each tool is designed to be:
25
+
26
+ - **Single-purpose**: Solves one specific problem independently
27
+ - **Shareable**: Can be featured in standalone videos/tutorials
28
+ - **Maintainable**: Clear separation of concerns between CLI and business logic
29
+ - **Testable**: Business logic separated from CLI interface
30
+
31
+ The architecture supports three distinct patterns, each suited for different tool complexity levels.
32
+
33
+ ---
34
+
35
+ ## Philosophy
36
+
37
+ ### Separation of Concerns
38
+
39
+ ```
40
+ bin/
41
+ ├── tool_name.rb ← CLI interface (OptionParser, argument routing)
42
+ lib/appydave/tools/
43
+ ├── tool_name/
44
+ │ ├── business_logic.rb ← Core functionality (pure Ruby, no CLI dependencies)
45
+ ```
46
+
47
+ **Key Principles:**
48
+ 1. **CLI layer** (`bin/`) handles argument parsing, user interaction, help messages
49
+ 2. **Business logic layer** (`lib/`) contains pure Ruby classes with no CLI dependencies
50
+ 3. **No CLI code in lib/** - business logic should be usable programmatically
51
+ 4. **No business logic in bin/** - executables should be thin wrappers
52
+
53
+ ### Module Organization
54
+
55
+ All business logic lives under the `Appydave::Tools::` namespace:
56
+
57
+ ```ruby
58
+ module Appydave
59
+ module Tools
60
+ module ToolName
61
+ class BusinessLogic
62
+ # Pure Ruby implementation
63
+ end
64
+ end
65
+ end
66
+ end
67
+ ```
68
+
69
+ ---
70
+
71
+ ## The Three Patterns
72
+
73
+ ### Pattern 1: Single-Command Tools
74
+
75
+ **Use when:** The tool performs ONE operation with various options.
76
+
77
+ **Example:** `gpt_context.rb` - Gathers files for AI context
78
+
79
+ #### Structure
80
+
81
+ ```
82
+ bin/
83
+ ├── gpt_context.rb # Executable CLI
84
+ lib/appydave/tools/
85
+ ├── gpt_context/
86
+ │ ├── options.rb # Options struct/class
87
+ │ ├── file_collector.rb # Core business logic
88
+ │ ├── output_handler.rb # Output processing
89
+ │ └── _doc.md # Documentation
90
+ ```
91
+
92
+ #### Implementation Pattern
93
+
94
+ **bin/gpt_context.rb:**
95
+ ```ruby
96
+ #!/usr/bin/env ruby
97
+ # frozen_string_literal: true
98
+
99
+ $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
100
+ require 'appydave/tools'
101
+
102
+ # 1. Define options object
103
+ options = Appydave::Tools::GptContext::Options.new(
104
+ working_directory: nil
105
+ )
106
+
107
+ # 2. Parse command-line arguments
108
+ OptionParser.new do |opts|
109
+ opts.banner = 'Usage: gpt_context.rb [options]'
110
+
111
+ opts.on('-i', '--include PATTERN', 'Pattern to include') do |pattern|
112
+ options.include_patterns << pattern
113
+ end
114
+
115
+ opts.on('-e', '--exclude PATTERN', 'Pattern to exclude') do |pattern|
116
+ options.exclude_patterns << pattern
117
+ end
118
+
119
+ opts.on_tail('-h', '--help', 'Show this message') do
120
+ puts opts
121
+ exit
122
+ end
123
+ end.parse!
124
+
125
+ # 3. Validate and set defaults
126
+ if options.include_patterns.empty?
127
+ puts 'No options provided. Please specify patterns to include.'
128
+ exit
129
+ end
130
+
131
+ options.working_directory ||= Dir.pwd
132
+
133
+ # 4. Execute business logic
134
+ gatherer = Appydave::Tools::GptContext::FileCollector.new(options)
135
+ content = gatherer.build
136
+
137
+ output_handler = Appydave::Tools::GptContext::OutputHandler.new(content, options)
138
+ output_handler.execute
139
+ ```
140
+
141
+ **lib/appydave/tools/gpt_context/options.rb:**
142
+ ```ruby
143
+ # frozen_string_literal: true
144
+
145
+ module Appydave
146
+ module Tools
147
+ module GptContext
148
+ Options = Struct.new(
149
+ :include_patterns,
150
+ :exclude_patterns,
151
+ :format,
152
+ :working_directory,
153
+ keyword_init: true
154
+ ) do
155
+ def initialize(**args)
156
+ super
157
+ self.include_patterns ||= []
158
+ self.exclude_patterns ||= []
159
+ self.format ||= 'tree,content'
160
+ end
161
+ end
162
+ end
163
+ end
164
+ end
165
+ ```
166
+
167
+ **lib/appydave/tools/gpt_context/file_collector.rb:**
168
+ ```ruby
169
+ # frozen_string_literal: true
170
+
171
+ module Appydave
172
+ module Tools
173
+ module GptContext
174
+ class FileCollector
175
+ def initialize(options)
176
+ @options = options
177
+ @include_patterns = options.include_patterns
178
+ @exclude_patterns = options.exclude_patterns
179
+ @working_directory = File.expand_path(options.working_directory)
180
+ end
181
+
182
+ def build
183
+ FileUtils.cd(@working_directory) if Dir.exist?(@working_directory)
184
+
185
+ # Business logic here - no CLI dependencies
186
+ content = build_content
187
+
188
+ FileUtils.cd(Dir.home)
189
+ content
190
+ end
191
+
192
+ private
193
+
194
+ def build_content
195
+ # Pure Ruby implementation
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end
201
+ ```
202
+
203
+ **Characteristics:**
204
+ - ✅ Simple, linear execution flow
205
+ - ✅ All options defined upfront in one OptionParser block
206
+ - ✅ Single business logic entry point
207
+ - ✅ Minimal routing logic
208
+ - ❌ Not suitable for multiple distinct operations
209
+
210
+ ---
211
+
212
+ ### Pattern 2: Multi-Command with Inline Routing
213
+
214
+ **Use when:** The tool has 2-5 related commands with simple routing needs.
215
+
216
+ **Example:** `subtitle_processor.rb` - Clean and join SRT files
217
+
218
+ #### Structure
219
+
220
+ ```
221
+ bin/
222
+ ├── subtitle_processor.rb # Executable with command routing
223
+ lib/appydave/tools/
224
+ ├── subtitle_processor/
225
+ │ ├── clean.rb # Command implementation
226
+ │ ├── join.rb # Command implementation
227
+ │ ├── _doc-clean.md # Per-command documentation
228
+ │ └── _doc-join.md
229
+ ```
230
+
231
+ #### Implementation Pattern
232
+
233
+ **bin/subtitle_processor.rb:**
234
+ ```ruby
235
+ #!/usr/bin/env ruby
236
+ # frozen_string_literal: true
237
+
238
+ require 'optparse'
239
+
240
+ $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
241
+ require 'appydave/tools'
242
+
243
+ # CLI class with inline command routing
244
+ class SubtitleProcessorCLI
245
+ def initialize
246
+ # Map commands to methods (inline routing)
247
+ @commands = {
248
+ 'clean' => method(:clean_subtitles),
249
+ 'join' => method(:join_subtitles)
250
+ }
251
+ end
252
+
253
+ def run
254
+ command, *args = ARGV
255
+
256
+ if command.nil?
257
+ puts 'No command provided. Use -h for help.'
258
+ print_help
259
+ exit
260
+ end
261
+
262
+ if @commands.key?(command)
263
+ @commands[command].call(args)
264
+ else
265
+ puts "Unknown command: #{command}"
266
+ print_help
267
+ end
268
+ end
269
+
270
+ private
271
+
272
+ # Command-specific method with dedicated OptionParser
273
+ def clean_subtitles(args)
274
+ options = { file: nil, output: nil }
275
+
276
+ clean_parser = OptionParser.new do |opts|
277
+ opts.banner = 'Usage: subtitle_processor.rb clean [options]'
278
+
279
+ opts.on('-f', '--file FILE', 'SRT file to process') do |v|
280
+ options[:file] = v
281
+ end
282
+
283
+ opts.on('-o', '--output FILE', 'Output file') do |v|
284
+ options[:output] = v
285
+ end
286
+
287
+ opts.on('-h', '--help', 'Show this message') do
288
+ puts opts
289
+ exit
290
+ end
291
+ end
292
+
293
+ begin
294
+ clean_parser.parse!(args)
295
+ rescue OptionParser::InvalidOption => e
296
+ puts "Error: #{e.message}"
297
+ puts clean_parser
298
+ exit
299
+ end
300
+
301
+ # Validate required options
302
+ if options[:file].nil? || options[:output].nil?
303
+ puts 'Error: Missing required options.'
304
+ puts clean_parser
305
+ exit
306
+ end
307
+
308
+ # Execute business logic
309
+ cleaner = Appydave::Tools::SubtitleProcessor::Clean.new(file_path: options[:file])
310
+ cleaner.clean
311
+ cleaner.write(options[:output])
312
+ end
313
+
314
+ def join_subtitles(args)
315
+ options = {
316
+ folder: './',
317
+ files: '*.srt',
318
+ sort: 'inferred',
319
+ buffer: 100,
320
+ output: 'merged.srt'
321
+ }
322
+
323
+ join_parser = OptionParser.new do |opts|
324
+ opts.banner = 'Usage: subtitle_processor.rb join [options]'
325
+
326
+ opts.on('-d', '--directory DIR', 'Directory containing SRT files') do |v|
327
+ options[:folder] = v
328
+ end
329
+
330
+ opts.on('-f', '--files PATTERN', 'File pattern') do |v|
331
+ options[:files] = v
332
+ end
333
+
334
+ opts.on('-o', '--output FILE', 'Output file') do |v|
335
+ options[:output] = v
336
+ end
337
+
338
+ opts.on('-h', '--help', 'Show this message') do
339
+ puts opts
340
+ exit
341
+ end
342
+ end
343
+
344
+ begin
345
+ join_parser.parse!(args)
346
+ rescue OptionParser::InvalidOption => e
347
+ puts "Error: #{e.message}"
348
+ puts join_parser
349
+ exit
350
+ end
351
+
352
+ # Execute business logic
353
+ joiner = Appydave::Tools::SubtitleProcessor::Join.new(
354
+ folder: options[:folder],
355
+ files: options[:files],
356
+ sort: options[:sort],
357
+ buffer: options[:buffer],
358
+ output: options[:output]
359
+ )
360
+ joiner.join
361
+ end
362
+
363
+ def print_help
364
+ puts 'Usage: subtitle_processor.rb [command] [options]'
365
+ puts 'Commands:'
366
+ puts ' clean Clean and normalize SRT files'
367
+ puts ' join Join multiple SRT files'
368
+ puts "Run 'subtitle_processor.rb [command] --help' for more information."
369
+ end
370
+ end
371
+
372
+ SubtitleProcessorCLI.new.run
373
+ ```
374
+
375
+ **lib/appydave/tools/subtitle_processor/clean.rb:**
376
+ ```ruby
377
+ # frozen_string_literal: true
378
+
379
+ module Appydave
380
+ module Tools
381
+ module SubtitleProcessor
382
+ # Clean and normalize subtitles
383
+ class Clean
384
+ attr_reader :content
385
+
386
+ def initialize(file_path: nil, srt_content: nil)
387
+ if file_path && srt_content
388
+ raise ArgumentError, 'Cannot provide both file path and content.'
389
+ elsif file_path.nil? && srt_content.nil?
390
+ raise ArgumentError, 'Must provide either file path or content.'
391
+ end
392
+
393
+ @content = file_path ? File.read(file_path, encoding: 'UTF-8') : srt_content
394
+ end
395
+
396
+ def clean
397
+ content = remove_underscores(@content)
398
+ normalize_lines(content)
399
+ end
400
+
401
+ def write(output_file)
402
+ File.write(output_file, content)
403
+ puts "Processed file written to #{output_file}"
404
+ rescue StandardError => e
405
+ puts "Error writing file: #{e.message}"
406
+ end
407
+
408
+ private
409
+
410
+ def remove_underscores(content)
411
+ content.gsub(%r{</?u>}, '')
412
+ end
413
+
414
+ def normalize_lines(content)
415
+ # Business logic - no CLI dependencies
416
+ end
417
+ end
418
+ end
419
+ end
420
+ end
421
+ ```
422
+
423
+ **lib/appydave/tools/subtitle_processor/join.rb:**
424
+ ```ruby
425
+ # frozen_string_literal: true
426
+
427
+ module Appydave
428
+ module Tools
429
+ module SubtitleProcessor
430
+ # Join multiple SRT files into one
431
+ class Join
432
+ def initialize(folder: './', files: '*.srt', sort: 'inferred', buffer: 100, output: 'merged.srt')
433
+ @folder = folder
434
+ @files = files
435
+ @sort = sort
436
+ @buffer = buffer
437
+ @output = output
438
+ end
439
+
440
+ def join
441
+ resolved_files = resolve_files
442
+ subtitle_groups = parse_files(resolved_files)
443
+ merged_subtitles = merge_subtitles(subtitle_groups)
444
+ write_output(merged_subtitles)
445
+ end
446
+
447
+ private
448
+
449
+ # Business logic - pure Ruby implementation
450
+ end
451
+ end
452
+ end
453
+ end
454
+ ```
455
+
456
+ **Characteristics:**
457
+ - ✅ Handles 2-5 related commands efficiently
458
+ - ✅ Each command has dedicated OptionParser with command-specific options
459
+ - ✅ Inline routing keeps everything in one file (easier to understand)
460
+ - ✅ Command methods keep CLI and business logic separated
461
+ - ❌ Can become cluttered with 6+ commands
462
+ - ❌ No shared validation/execution patterns
463
+
464
+ ---
465
+
466
+ ### Pattern 3: Multi-Command with BaseAction
467
+
468
+ **Use when:** The tool has multiple commands that share validation/execution patterns.
469
+
470
+ **Example:** `youtube_manager.rb` - Get and update YouTube videos
471
+
472
+ #### Structure
473
+
474
+ ```
475
+ bin/
476
+ ├── youtube_manager.rb # Executable with BaseAction routing
477
+ lib/appydave/tools/
478
+ ├── cli_actions/
479
+ │ ├── base_action.rb # Abstract base class
480
+ │ ├── get_video_action.rb # Command implementation
481
+ │ └── update_video_action.rb # Command implementation
482
+ ├── youtube_manager/
483
+ │ ├── authorization.rb # Shared business logic
484
+ │ ├── get_video.rb # Core functionality
485
+ │ └── update_video.rb
486
+ ```
487
+
488
+ #### Implementation Pattern
489
+
490
+ **lib/appydave/tools/cli_actions/base_action.rb:**
491
+ ```ruby
492
+ # frozen_string_literal: true
493
+
494
+ module Appydave
495
+ module Tools
496
+ module CliActions
497
+ # Base class for CLI actions
498
+ class BaseAction
499
+ # Entry point called by bin/ executable
500
+ def action(args)
501
+ options = parse_options(args)
502
+ execute(options)
503
+ end
504
+
505
+ protected
506
+
507
+ # Template method - parse with standard pattern
508
+ def parse_options(args)
509
+ options = {}
510
+ OptionParser.new do |opts|
511
+ opts.banner = "Usage: #{command_usage}"
512
+
513
+ define_options(opts, options)
514
+
515
+ opts.on_tail('-h', '--help', 'Show this message') do
516
+ puts opts
517
+ exit
518
+ end
519
+ end.parse!(args)
520
+
521
+ validate_options(options)
522
+ options
523
+ end
524
+
525
+ # Hook: Define command usage string
526
+ def command_usage
527
+ "#{self.class.name.split('::').last.downcase} [options]"
528
+ end
529
+
530
+ # Hook: Subclass defines command-specific options
531
+ def define_options(opts, options)
532
+ # To be implemented by subclasses
533
+ end
534
+
535
+ # Hook: Subclass validates required options
536
+ def validate_options(options)
537
+ # To be implemented by subclasses
538
+ end
539
+
540
+ # Hook: Subclass executes business logic
541
+ def execute(options)
542
+ # To be implemented by subclasses
543
+ end
544
+ end
545
+ end
546
+ end
547
+ end
548
+ ```
549
+
550
+ **lib/appydave/tools/cli_actions/get_video_action.rb:**
551
+ ```ruby
552
+ # frozen_string_literal: true
553
+
554
+ module Appydave
555
+ module Tools
556
+ module CliActions
557
+ # CLI Action to get a YouTube video details
558
+ class GetVideoAction < BaseAction
559
+ protected
560
+
561
+ def define_options(opts, options)
562
+ opts.on('-v', '--video-id ID', 'YouTube Video ID') { |v| options[:video_id] = v }
563
+ end
564
+
565
+ def validate_options(options)
566
+ return if options[:video_id]
567
+
568
+ puts 'Missing required options: --video-id. Use -h for help.'
569
+ exit
570
+ end
571
+
572
+ def execute(options)
573
+ get_video = Appydave::Tools::YouTubeManager::GetVideo.new
574
+ get_video.get(options[:video_id])
575
+
576
+ if get_video.video?
577
+ report = Appydave::Tools::YouTubeManager::Reports::VideoDetailsReport.new
578
+ report.print(get_video.data)
579
+ else
580
+ puts "Video not found! Maybe it's private or deleted. ID: #{options[:video_id]}"
581
+ end
582
+ end
583
+ end
584
+ end
585
+ end
586
+ end
587
+ ```
588
+
589
+ **lib/appydave/tools/cli_actions/update_video_action.rb:**
590
+ ```ruby
591
+ # frozen_string_literal: true
592
+
593
+ module Appydave
594
+ module Tools
595
+ module CliActions
596
+ # Action to update a YouTube video metadata
597
+ class UpdateVideoAction < BaseAction
598
+ protected
599
+
600
+ def define_options(opts, options)
601
+ opts.on('-v', '--video-id ID', 'YouTube Video ID') { |v| options[:video_id] = v }
602
+ opts.on('-t', '--title TITLE', 'Video Title') { |t| options[:title] = t }
603
+ opts.on('-d', '--description DESCRIPTION', 'Video Description') { |d| options[:description] = d }
604
+ opts.on('-g', '--tags TAGS', 'Video Tags (comma-separated)') { |g| options[:tags] = g.split(',') }
605
+ opts.on('-c', '--category-id CATEGORY_ID', 'Video Category ID') { |c| options[:category_id] = c }
606
+ end
607
+
608
+ def validate_options(options)
609
+ return if options[:video_id]
610
+
611
+ puts 'Missing required options: --video-id. Use -h for help.'
612
+ exit
613
+ end
614
+
615
+ def execute(options)
616
+ get_video = Appydave::Tools::YouTubeManager::GetVideo.new
617
+ get_video.get(options[:video_id])
618
+
619
+ if get_video.video?
620
+ update_video = Appydave::Tools::YouTubeManager::UpdateVideo.new(get_video.data)
621
+
622
+ update_video.title(options[:title]) if options[:title]
623
+ update_video.description(options[:description]) if options[:description]
624
+ update_video.tags(options[:tags]) if options[:tags]
625
+ update_video.category_id(options[:category_id]) if options[:category_id]
626
+
627
+ update_video.save
628
+ puts "Video updated successfully. ID: #{options[:video_id]}"
629
+ else
630
+ puts "Video not found! ID: #{options[:video_id]}"
631
+ end
632
+ end
633
+ end
634
+ end
635
+ end
636
+ end
637
+ ```
638
+
639
+ **bin/youtube_manager.rb:**
640
+ ```ruby
641
+ #!/usr/bin/env ruby
642
+ # frozen_string_literal: true
643
+
644
+ $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
645
+ require 'appydave/tools'
646
+
647
+ # CLI with BaseAction routing
648
+ class YouTubeVideoManagerCLI
649
+ include KLog::Logging
650
+
651
+ def initialize
652
+ # Map commands to action classes
653
+ @commands = {
654
+ 'get' => Appydave::Tools::CliActions::GetVideoAction.new,
655
+ 'update' => Appydave::Tools::CliActions::UpdateVideoAction.new
656
+ }
657
+ end
658
+
659
+ def run
660
+ command, *args = ARGV
661
+
662
+ if @commands.key?(command)
663
+ @commands[command].action(args)
664
+ else
665
+ puts "Unknown command: #{command}"
666
+ print_help
667
+ end
668
+ end
669
+
670
+ private
671
+
672
+ def print_help
673
+ puts 'Usage: youtube_manager.rb [command] [options]'
674
+ puts 'Commands:'
675
+ puts ' get Get details for a YouTube video'
676
+ puts ' update Update details for a YouTube video'
677
+ puts "Run 'youtube_manager.rb [command] --help' for more information."
678
+ end
679
+ end
680
+
681
+ YouTubeVideoManagerCLI.new.run
682
+ ```
683
+
684
+ **Characteristics:**
685
+ - ✅ Scales well to 6+ commands
686
+ - ✅ Enforces consistent patterns across commands (template method)
687
+ - ✅ Shared validation and execution flow
688
+ - ✅ Easy to add new commands (create new Action subclass)
689
+ - ✅ Testable action classes (can test independently)
690
+ - ❌ More complex than inline routing for simple tools
691
+ - ❌ Requires understanding template method pattern
692
+
693
+ ---
694
+
695
+ ## Decision Tree
696
+
697
+ Use this flowchart to choose the right pattern:
698
+
699
+ ```
700
+ Start: How many distinct operations does your tool perform?
701
+
702
+ ├─ One operation (with various options)
703
+ │ └─ Pattern 1: Single-Command Tool
704
+ │ Examples: gpt_context, move_images
705
+
706
+ ├─ 2-5 operations (simple, independent commands)
707
+ │ └─ Pattern 2: Multi-Command with Inline Routing
708
+ │ Examples: subtitle_processor, configuration
709
+
710
+ └─ 6+ operations OR commands share validation/execution patterns
711
+ └─ Pattern 3: Multi-Command with BaseAction
712
+ Examples: youtube_manager
713
+ ```
714
+
715
+ **Additional Considerations:**
716
+
717
+ | Question | Pattern 1 | Pattern 2 | Pattern 3 |
718
+ |----------|-----------|-----------|-----------|
719
+ | Commands share business logic? | N/A | ❌ Duplicate code | ✅ Shared via base class |
720
+ | Tool might grow to 10+ commands? | ❌ Wrong pattern | ⚠️ Will need refactor | ✅ Scales well |
721
+ | Need programmatic API? | ✅ Yes | ✅ Yes | ✅ Yes |
722
+ | Commands have different option patterns? | N/A | ✅ Easy | ✅ Easy |
723
+ | Team familiar with OOP patterns? | ✅ Simple | ✅ Simple | ⚠️ Requires understanding |
724
+
725
+ ---
726
+
727
+ ## Directory Structure
728
+
729
+ ### Pattern 1: Single-Command Tool
730
+
731
+ ```
732
+ appydave-tools/
733
+ ├── bin/
734
+ │ └── tool_name.rb # Executable
735
+ ├── lib/appydave/tools/
736
+ │ └── tool_name/
737
+ │ ├── options.rb # Options struct/class
738
+ │ ├── main_logic.rb # Core business logic
739
+ │ ├── supporting_class.rb # Supporting functionality
740
+ │ └── _doc.md # Documentation
741
+ └── spec/appydave/tools/
742
+ └── tool_name/
743
+ ├── main_logic_spec.rb
744
+ └── supporting_class_spec.rb
745
+ ```
746
+
747
+ ### Pattern 2: Multi-Command with Inline Routing
748
+
749
+ ```
750
+ appydave-tools/
751
+ ├── bin/
752
+ │ └── tool_name.rb # Executable with CLI class
753
+ ├── lib/appydave/tools/
754
+ │ └── tool_name/
755
+ │ ├── command_one.rb # Command implementation
756
+ │ ├── command_two.rb # Command implementation
757
+ │ ├── _doc-command-one.md # Per-command docs
758
+ │ └── _doc-command-two.md
759
+ └── spec/appydave/tools/
760
+ └── tool_name/
761
+ ├── command_one_spec.rb
762
+ └── command_two_spec.rb
763
+ ```
764
+
765
+ ### Pattern 3: Multi-Command with BaseAction
766
+
767
+ ```
768
+ appydave-tools/
769
+ ├── bin/
770
+ │ └── tool_name.rb # Executable with routing
771
+ ├── lib/appydave/tools/
772
+ │ ├── cli_actions/ # Shared across tools
773
+ │ │ ├── base_action.rb # Abstract base
774
+ │ │ ├── tool_command_one_action.rb # Command implementation
775
+ │ │ └── tool_command_two_action.rb
776
+ │ └── tool_name/ # Business logic
777
+ │ ├── service_one.rb
778
+ │ ├── service_two.rb
779
+ │ └── _doc.md
780
+ └── spec/appydave/tools/
781
+ ├── cli_actions/
782
+ │ ├── tool_command_one_action_spec.rb
783
+ │ └── tool_command_two_action_spec.rb
784
+ └── tool_name/
785
+ ├── service_one_spec.rb
786
+ └── service_two_spec.rb
787
+ ```
788
+
789
+ ---
790
+
791
+ ## Best Practices
792
+
793
+ ### Naming Conventions
794
+
795
+ #### Executables (`bin/`)
796
+ - Use **snake_case** for script names
797
+ - Match the gem command name when installed
798
+ - Examples: `gpt_context.rb`, `subtitle_processor.rb`, `youtube_manager.rb`
799
+
800
+ #### Modules (`lib/`)
801
+ - Use **PascalCase** for module/class names
802
+ - Match directory structure: `lib/appydave/tools/tool_name/` → `Appydave::Tools::ToolName`
803
+ - Examples: `GptContext`, `SubtitleProcessor`, `YouTubeManager`
804
+
805
+ #### Files
806
+ - Use **snake_case** for Ruby files
807
+ - Match class name: `file_collector.rb` → `class FileCollector`
808
+ - Prefix docs with underscore: `_doc.md`, `_doc-command.md`
809
+
810
+ ### Code Organization
811
+
812
+ #### 1. Frozen String Literal
813
+ All Ruby files must start with:
814
+ ```ruby
815
+ # frozen_string_literal: true
816
+ ```
817
+
818
+ #### 2. Load Path Setup (bin/ only)
819
+ ```ruby
820
+ $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
821
+ require 'appydave/tools'
822
+ ```
823
+
824
+ #### 3. Module Nesting
825
+ ```ruby
826
+ module Appydave
827
+ module Tools
828
+ module ToolName
829
+ class BusinessLogic
830
+ # Implementation
831
+ end
832
+ end
833
+ end
834
+ end
835
+ ```
836
+
837
+ #### 4. Separation of Concerns
838
+
839
+ **✅ Good: Business logic independent of CLI**
840
+ ```ruby
841
+ # lib/appydave/tools/tool_name/processor.rb
842
+ class Processor
843
+ def initialize(file_path:, options: {})
844
+ @file_path = file_path
845
+ @options = options
846
+ end
847
+
848
+ def process
849
+ # Pure Ruby - no CLI dependencies
850
+ end
851
+ end
852
+
853
+ # bin/tool_name.rb
854
+ processor = Processor.new(file_path: options[:file], options: parsed_options)
855
+ processor.process
856
+ ```
857
+
858
+ **❌ Bad: Business logic coupled to CLI**
859
+ ```ruby
860
+ # lib/appydave/tools/tool_name/processor.rb
861
+ class Processor
862
+ def process
863
+ puts "Processing..." # CLI output in business logic
864
+ ARGV.each do |arg| # Direct ARGV access
865
+ # ...
866
+ end
867
+ end
868
+ end
869
+ ```
870
+
871
+ #### 5. Error Handling
872
+
873
+ **In business logic:**
874
+ ```ruby
875
+ def validate!
876
+ raise ArgumentError, 'File path required' if file_path.nil?
877
+ raise Errno::ENOENT, "File not found: #{file_path}" unless File.exist?(file_path)
878
+ end
879
+ ```
880
+
881
+ **In CLI layer:**
882
+ ```ruby
883
+ begin
884
+ processor.process
885
+ rescue ArgumentError => e
886
+ puts "Error: #{e.message}"
887
+ exit 1
888
+ rescue Errno::ENOENT => e
889
+ puts "File error: #{e.message}"
890
+ exit 1
891
+ end
892
+ ```
893
+
894
+ ### OptionParser Patterns
895
+
896
+ #### Standard Help Option
897
+ ```ruby
898
+ opts.on_tail('-h', '--help', 'Show this message') do
899
+ puts opts
900
+ exit
901
+ end
902
+ ```
903
+
904
+ #### Required Options Validation
905
+ ```ruby
906
+ def validate_options(options)
907
+ missing = []
908
+ missing << '--file' if options[:file].nil?
909
+ missing << '--output' if options[:output].nil?
910
+
911
+ return if missing.empty?
912
+
913
+ puts "Missing required options: #{missing.join(', ')}"
914
+ puts "Use -h for help."
915
+ exit 1
916
+ end
917
+ ```
918
+
919
+ #### Array Options (Multiple Values)
920
+ ```ruby
921
+ opts.on('-i', '--include PATTERN', 'Pattern to include (can be used multiple times)') do |pattern|
922
+ options[:include_patterns] << pattern
923
+ end
924
+ ```
925
+
926
+ #### Enum Options (Fixed Choices)
927
+ ```ruby
928
+ opts.on('-s', '--sort ORDER', %w[asc desc inferred], 'Sort order (asc/desc/inferred)') do |v|
929
+ options[:sort] = v
930
+ end
931
+ ```
932
+
933
+ #### Type Coercion
934
+ ```ruby
935
+ opts.on('-b', '--buffer MS', Integer, 'Buffer in milliseconds') do |v|
936
+ options[:buffer] = v
937
+ end
938
+ ```
939
+
940
+ ### Documentation
941
+
942
+ #### Per-Module Documentation
943
+ Create `_doc.md` files in each module directory:
944
+
945
+ ```markdown
946
+ # ToolName Module
947
+
948
+ ## Purpose
949
+ Brief description of what this module does.
950
+
951
+ ## Classes
952
+ - `MainClass` - Primary functionality
953
+ - `SupportingClass` - Supporting functionality
954
+
955
+ ## Usage
956
+ ruby
957
+ # Example code
958
+ ```
959
+
960
+ #### Per-Command Documentation (Pattern 2)
961
+ For multi-command tools with inline routing:
962
+
963
+ ```
964
+ lib/appydave/tools/tool_name/
965
+ ├── _doc.md # Overall module documentation
966
+ ├── _doc-clean.md # 'clean' command documentation
967
+ └── _doc-join.md # 'join' command documentation
968
+ ```
969
+
970
+ ---
971
+
972
+ ## Testing Approach
973
+
974
+ ### Test Structure
975
+ Tests mirror the `lib/` structure under `spec/`:
976
+
977
+ ```
978
+ lib/appydave/tools/tool_name/processor.rb
979
+ spec/appydave/tools/tool_name/processor_spec.rb
980
+ ```
981
+
982
+ ### RSpec Conventions
983
+
984
+ #### No Require Statements
985
+ All requires are handled by `spec_helper.rb`:
986
+
987
+ ```ruby
988
+ # spec/appydave/tools/tool_name/processor_spec.rb
989
+ # frozen_string_literal: true
990
+
991
+ # NO require statements needed
992
+
993
+ RSpec.describe Appydave::Tools::ToolName::Processor do
994
+ # Tests
995
+ end
996
+ ```
997
+
998
+ #### Test Business Logic, Not CLI
999
+ Focus tests on business logic classes, not bin/ executables:
1000
+
1001
+ **✅ Good:**
1002
+ ```ruby
1003
+ RSpec.describe Appydave::Tools::SubtitleProcessor::Clean do
1004
+ subject { described_class.new(srt_content: sample_srt) }
1005
+
1006
+ describe '#clean' do
1007
+ it 'removes HTML tags' do
1008
+ expect(subject.clean).not_to include('<u>')
1009
+ end
1010
+ end
1011
+ end
1012
+ ```
1013
+
1014
+ **❌ Avoid:**
1015
+ ```ruby
1016
+ # Testing bin/ executables is fragile and couples tests to CLI
1017
+ RSpec.describe 'bin/subtitle_processor.rb' do
1018
+ it 'runs clean command' do
1019
+ `bin/subtitle_processor.rb clean -f test.srt -o output.srt`
1020
+ # ...
1021
+ end
1022
+ end
1023
+ ```
1024
+
1025
+ #### Guard for Continuous Testing
1026
+ Use Guard to auto-run tests and RuboCop:
1027
+
1028
+ ```bash
1029
+ guard
1030
+ # Watches file changes and runs relevant tests
1031
+ ```
1032
+
1033
+ ---
1034
+
1035
+ ## Migration Guide
1036
+
1037
+ ### Adding a New Tool to appydave-tools
1038
+
1039
+ Follow these steps to integrate a new tool following established patterns:
1040
+
1041
+ #### Step 1: Choose Your Pattern
1042
+ Use the [Decision Tree](#decision-tree) to select Pattern 1, 2, or 3.
1043
+
1044
+ #### Step 2: Create Directory Structure
1045
+
1046
+ **Pattern 1 (Single-Command):**
1047
+ ```bash
1048
+ mkdir -p lib/appydave/tools/new_tool
1049
+ touch bin/new_tool.rb
1050
+ touch lib/appydave/tools/new_tool/options.rb
1051
+ touch lib/appydave/tools/new_tool/main_logic.rb
1052
+ touch lib/appydave/tools/new_tool/_doc.md
1053
+ chmod +x bin/new_tool.rb
1054
+ ```
1055
+
1056
+ **Pattern 2 (Multi-Command Inline):**
1057
+ ```bash
1058
+ mkdir -p lib/appydave/tools/new_tool
1059
+ touch bin/new_tool.rb
1060
+ touch lib/appydave/tools/new_tool/command_one.rb
1061
+ touch lib/appydave/tools/new_tool/command_two.rb
1062
+ touch lib/appydave/tools/new_tool/_doc.md
1063
+ chmod +x bin/new_tool.rb
1064
+ ```
1065
+
1066
+ **Pattern 3 (Multi-Command BaseAction):**
1067
+ ```bash
1068
+ mkdir -p lib/appydave/tools/new_tool
1069
+ touch bin/new_tool.rb
1070
+ touch lib/appydave/tools/cli_actions/new_tool_command_one_action.rb
1071
+ touch lib/appydave/tools/cli_actions/new_tool_command_two_action.rb
1072
+ touch lib/appydave/tools/new_tool/service.rb
1073
+ touch lib/appydave/tools/new_tool/_doc.md
1074
+ chmod +x bin/new_tool.rb
1075
+ ```
1076
+
1077
+ #### Step 3: Implement Using Pattern Template
1078
+ Copy the appropriate pattern template from [Examples](#examples) section.
1079
+
1080
+ #### Step 4: Register as Gem Executable
1081
+ Edit `appydave-tools.gemspec`:
1082
+
1083
+ ```ruby
1084
+ spec.executables = [
1085
+ 'gpt_context',
1086
+ 'subtitle_processor',
1087
+ 'youtube_manager',
1088
+ 'new_tool' # Add your tool
1089
+ ]
1090
+ ```
1091
+
1092
+ #### Step 5: Write Tests
1093
+ Create corresponding spec files:
1094
+
1095
+ ```bash
1096
+ mkdir -p spec/appydave/tools/new_tool
1097
+ touch spec/appydave/tools/new_tool/main_logic_spec.rb
1098
+ ```
1099
+
1100
+ #### Step 6: Document in CLAUDE.md
1101
+ Add to the "Quick Reference Index" table in `CLAUDE.md`:
1102
+
1103
+ ```markdown
1104
+ | Command | Gem Command | Description | Status |
1105
+ |---------|-------------|-------------|--------|
1106
+ | **New Tool** | `new_tool` | Brief description | ✅ ACTIVE |
1107
+ ```
1108
+
1109
+ Add detailed usage section:
1110
+
1111
+ ```markdown
1112
+ #### X. New Tool (`bin/new_tool.rb`)
1113
+ Brief description of what the tool does:
1114
+
1115
+ bash
1116
+ # Usage examples
1117
+ bin/new_tool.rb command --option value
1118
+ ```
1119
+
1120
+ #### Step 7: Test Locally
1121
+ ```bash
1122
+ # Install gem locally
1123
+ rake build
1124
+ gem install pkg/appydave-tools-X.Y.Z.gem
1125
+
1126
+ # Test as system command
1127
+ new_tool --help
1128
+
1129
+ # Run tests
1130
+ rake spec
1131
+ ```
1132
+
1133
+ ### Migrating Existing Code
1134
+
1135
+ If you have existing standalone scripts to migrate:
1136
+
1137
+ #### 1. Identify Business Logic
1138
+ Separate pure Ruby logic from CLI interface:
1139
+
1140
+ **Before:**
1141
+ ```ruby
1142
+ # bin/standalone_script.rb
1143
+ #!/usr/bin/env ruby
1144
+
1145
+ file = ARGV[0]
1146
+ puts "Processing #{file}..."
1147
+
1148
+ content = File.read(file)
1149
+ cleaned = content.gsub(/pattern/, 'replacement')
1150
+
1151
+ File.write('output.txt', cleaned)
1152
+ puts "Done!"
1153
+ ```
1154
+
1155
+ **After:**
1156
+ ```ruby
1157
+ # bin/new_tool.rb
1158
+ #!/usr/bin/env ruby
1159
+ # frozen_string_literal: true
1160
+
1161
+ $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
1162
+ require 'appydave/tools'
1163
+
1164
+ options = {}
1165
+ OptionParser.new do |opts|
1166
+ opts.on('-f', '--file FILE', 'Input file') { |v| options[:file] = v }
1167
+ opts.on('-o', '--output FILE', 'Output file') { |v| options[:output] = v }
1168
+ end.parse!
1169
+
1170
+ processor = Appydave::Tools::NewTool::Processor.new(file: options[:file])
1171
+ processor.process
1172
+ processor.write(options[:output])
1173
+
1174
+ # lib/appydave/tools/new_tool/processor.rb
1175
+ module Appydave
1176
+ module Tools
1177
+ module NewTool
1178
+ class Processor
1179
+ def initialize(file:)
1180
+ @file = file
1181
+ @content = File.read(file)
1182
+ end
1183
+
1184
+ def process
1185
+ @content.gsub(/pattern/, 'replacement')
1186
+ end
1187
+
1188
+ def write(output_file)
1189
+ File.write(output_file, @content)
1190
+ end
1191
+ end
1192
+ end
1193
+ end
1194
+ end
1195
+ ```
1196
+
1197
+ #### 2. Extract Reusable Components
1198
+ If logic is shared across tools, extract to shared modules:
1199
+
1200
+ ```
1201
+ lib/appydave/tools/
1202
+ ├── types/ # Shared type system
1203
+ │ ├── base_model.rb
1204
+ │ └── hash_type.rb
1205
+ ├── configuration/ # Shared config management
1206
+ │ └── config.rb
1207
+ └── new_tool/ # Tool-specific logic
1208
+ └── processor.rb
1209
+ ```
1210
+
1211
+ ---
1212
+
1213
+ ## Examples
1214
+
1215
+ ### Complete Pattern 1 Example
1216
+
1217
+ **Scenario:** Create a tool that converts CSV to JSON
1218
+
1219
+ ```ruby
1220
+ # bin/csv_converter.rb
1221
+ #!/usr/bin/env ruby
1222
+ # frozen_string_literal: true
1223
+
1224
+ $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
1225
+ require 'appydave/tools'
1226
+
1227
+ options = Appydave::Tools::CsvConverter::Options.new
1228
+
1229
+ OptionParser.new do |opts|
1230
+ opts.banner = 'Usage: csv_converter.rb [options]'
1231
+
1232
+ opts.on('-i', '--input FILE', 'Input CSV file') do |v|
1233
+ options.input_file = v
1234
+ end
1235
+
1236
+ opts.on('-o', '--output FILE', 'Output JSON file') do |v|
1237
+ options.output_file = v
1238
+ end
1239
+
1240
+ opts.on('-d', '--delimiter CHAR', 'CSV delimiter (default: ,)') do |v|
1241
+ options.delimiter = v
1242
+ end
1243
+
1244
+ opts.on_tail('-h', '--help', 'Show this message') do
1245
+ puts opts
1246
+ exit
1247
+ end
1248
+ end.parse!
1249
+
1250
+ if options.input_file.nil? || options.output_file.nil?
1251
+ puts 'Missing required options. Use -h for help.'
1252
+ exit 1
1253
+ end
1254
+
1255
+ converter = Appydave::Tools::CsvConverter::Converter.new(options)
1256
+ converter.convert
1257
+ puts "Converted #{options.input_file} to #{options.output_file}"
1258
+ ```
1259
+
1260
+ ```ruby
1261
+ # lib/appydave/tools/csv_converter/options.rb
1262
+ # frozen_string_literal: true
1263
+
1264
+ module Appydave
1265
+ module Tools
1266
+ module CsvConverter
1267
+ Options = Struct.new(
1268
+ :input_file,
1269
+ :output_file,
1270
+ :delimiter,
1271
+ keyword_init: true
1272
+ ) do
1273
+ def initialize(**args)
1274
+ super
1275
+ self.delimiter ||= ','
1276
+ end
1277
+ end
1278
+ end
1279
+ end
1280
+ end
1281
+ ```
1282
+
1283
+ ```ruby
1284
+ # lib/appydave/tools/csv_converter/converter.rb
1285
+ # frozen_string_literal: true
1286
+
1287
+ require 'csv'
1288
+ require 'json'
1289
+
1290
+ module Appydave
1291
+ module Tools
1292
+ module CsvConverter
1293
+ class Converter
1294
+ def initialize(options)
1295
+ @input_file = options.input_file
1296
+ @output_file = options.output_file
1297
+ @delimiter = options.delimiter
1298
+ end
1299
+
1300
+ def convert
1301
+ data = parse_csv
1302
+ write_json(data)
1303
+ end
1304
+
1305
+ private
1306
+
1307
+ def parse_csv
1308
+ CSV.read(@input_file, col_sep: @delimiter, headers: true).map(&:to_h)
1309
+ end
1310
+
1311
+ def write_json(data)
1312
+ File.write(@output_file, JSON.pretty_generate(data))
1313
+ end
1314
+ end
1315
+ end
1316
+ end
1317
+ end
1318
+ ```
1319
+
1320
+ ### Complete Pattern 2 Example
1321
+
1322
+ **Scenario:** Create a tool with `encode` and `decode` commands
1323
+
1324
+ ```ruby
1325
+ # bin/text_processor.rb
1326
+ #!/usr/bin/env ruby
1327
+ # frozen_string_literal: true
1328
+
1329
+ require 'optparse'
1330
+
1331
+ $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
1332
+ require 'appydave/tools'
1333
+
1334
+ class TextProcessorCLI
1335
+ def initialize
1336
+ @commands = {
1337
+ 'encode' => method(:encode_text),
1338
+ 'decode' => method(:decode_text)
1339
+ }
1340
+ end
1341
+
1342
+ def run
1343
+ command, *args = ARGV
1344
+
1345
+ if command.nil?
1346
+ print_help
1347
+ exit
1348
+ end
1349
+
1350
+ if @commands.key?(command)
1351
+ @commands[command].call(args)
1352
+ else
1353
+ puts "Unknown command: #{command}"
1354
+ print_help
1355
+ end
1356
+ end
1357
+
1358
+ private
1359
+
1360
+ def encode_text(args)
1361
+ options = { file: nil, output: nil, algorithm: 'base64' }
1362
+
1363
+ OptionParser.new do |opts|
1364
+ opts.banner = 'Usage: text_processor.rb encode [options]'
1365
+
1366
+ opts.on('-f', '--file FILE', 'Input file') { |v| options[:file] = v }
1367
+ opts.on('-o', '--output FILE', 'Output file') { |v| options[:output] = v }
1368
+ opts.on('-a', '--algorithm ALG', %w[base64 rot13], 'Encoding algorithm') { |v| options[:algorithm] = v }
1369
+
1370
+ opts.on('-h', '--help', 'Show this message') do
1371
+ puts opts
1372
+ exit
1373
+ end
1374
+ end.parse!(args)
1375
+
1376
+ validate_options!(options)
1377
+
1378
+ encoder = Appydave::Tools::TextProcessor::Encoder.new(
1379
+ file: options[:file],
1380
+ algorithm: options[:algorithm]
1381
+ )
1382
+ encoder.encode
1383
+ encoder.write(options[:output])
1384
+ end
1385
+
1386
+ def decode_text(args)
1387
+ options = { file: nil, output: nil, algorithm: 'base64' }
1388
+
1389
+ OptionParser.new do |opts|
1390
+ opts.banner = 'Usage: text_processor.rb decode [options]'
1391
+
1392
+ opts.on('-f', '--file FILE', 'Input file') { |v| options[:file] = v }
1393
+ opts.on('-o', '--output FILE', 'Output file') { |v| options[:output] = v }
1394
+ opts.on('-a', '--algorithm ALG', %w[base64 rot13], 'Decoding algorithm') { |v| options[:algorithm] = v }
1395
+
1396
+ opts.on('-h', '--help', 'Show this message') do
1397
+ puts opts
1398
+ exit
1399
+ end
1400
+ end.parse!(args)
1401
+
1402
+ validate_options!(options)
1403
+
1404
+ decoder = Appydave::Tools::TextProcessor::Decoder.new(
1405
+ file: options[:file],
1406
+ algorithm: options[:algorithm]
1407
+ )
1408
+ decoder.decode
1409
+ decoder.write(options[:output])
1410
+ end
1411
+
1412
+ def validate_options!(options)
1413
+ if options[:file].nil? || options[:output].nil?
1414
+ puts 'Missing required options: --file and --output'
1415
+ exit 1
1416
+ end
1417
+ end
1418
+
1419
+ def print_help
1420
+ puts 'Usage: text_processor.rb [command] [options]'
1421
+ puts 'Commands:'
1422
+ puts ' encode Encode text file'
1423
+ puts ' decode Decode text file'
1424
+ puts "Run 'text_processor.rb [command] --help' for more information."
1425
+ end
1426
+ end
1427
+
1428
+ TextProcessorCLI.new.run
1429
+ ```
1430
+
1431
+ ```ruby
1432
+ # lib/appydave/tools/text_processor/encoder.rb
1433
+ # frozen_string_literal: true
1434
+
1435
+ require 'base64'
1436
+
1437
+ module Appydave
1438
+ module Tools
1439
+ module TextProcessor
1440
+ class Encoder
1441
+ def initialize(file:, algorithm: 'base64')
1442
+ @file = file
1443
+ @algorithm = algorithm
1444
+ @content = File.read(file)
1445
+ @encoded = nil
1446
+ end
1447
+
1448
+ def encode
1449
+ @encoded = case @algorithm
1450
+ when 'base64'
1451
+ Base64.strict_encode64(@content)
1452
+ when 'rot13'
1453
+ @content.tr('A-Za-z', 'N-ZA-Mn-za-m')
1454
+ end
1455
+ end
1456
+
1457
+ def write(output_file)
1458
+ File.write(output_file, @encoded)
1459
+ end
1460
+ end
1461
+ end
1462
+ end
1463
+ end
1464
+ ```
1465
+
1466
+ ### Complete Pattern 3 Example
1467
+
1468
+ **Scenario:** Create a tool with `list`, `create`, `delete` commands
1469
+
1470
+ ```ruby
1471
+ # lib/appydave/tools/cli_actions/base_action.rb (already exists)
1472
+
1473
+ # lib/appydave/tools/cli_actions/resource_list_action.rb
1474
+ # frozen_string_literal: true
1475
+
1476
+ module Appydave
1477
+ module Tools
1478
+ module CliActions
1479
+ class ResourceListAction < BaseAction
1480
+ protected
1481
+
1482
+ def define_options(opts, options)
1483
+ opts.on('-f', '--filter PATTERN', 'Filter pattern') { |v| options[:filter] = v }
1484
+ opts.on('-s', '--sort FIELD', 'Sort by field') { |v| options[:sort] = v }
1485
+ end
1486
+
1487
+ def validate_options(options)
1488
+ # All options are optional for list
1489
+ end
1490
+
1491
+ def execute(options)
1492
+ manager = Appydave::Tools::ResourceManager::Manager.new
1493
+ resources = manager.list(filter: options[:filter], sort: options[:sort])
1494
+
1495
+ resources.each do |resource|
1496
+ puts "#{resource.id}: #{resource.name}"
1497
+ end
1498
+ end
1499
+ end
1500
+ end
1501
+ end
1502
+ end
1503
+ ```
1504
+
1505
+ ```ruby
1506
+ # lib/appydave/tools/cli_actions/resource_create_action.rb
1507
+ # frozen_string_literal: true
1508
+
1509
+ module Appydave
1510
+ module Tools
1511
+ module CliActions
1512
+ class ResourceCreateAction < BaseAction
1513
+ protected
1514
+
1515
+ def define_options(opts, options)
1516
+ opts.on('-n', '--name NAME', 'Resource name') { |v| options[:name] = v }
1517
+ opts.on('-t', '--type TYPE', 'Resource type') { |v| options[:type] = v }
1518
+ end
1519
+
1520
+ def validate_options(options)
1521
+ return if options[:name] && options[:type]
1522
+
1523
+ puts 'Missing required options: --name and --type'
1524
+ exit 1
1525
+ end
1526
+
1527
+ def execute(options)
1528
+ manager = Appydave::Tools::ResourceManager::Manager.new
1529
+ resource = manager.create(name: options[:name], type: options[:type])
1530
+
1531
+ puts "Created resource: #{resource.id}"
1532
+ end
1533
+ end
1534
+ end
1535
+ end
1536
+ end
1537
+ ```
1538
+
1539
+ ```ruby
1540
+ # bin/resource_manager.rb
1541
+ #!/usr/bin/env ruby
1542
+ # frozen_string_literal: true
1543
+
1544
+ $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
1545
+ require 'appydave/tools'
1546
+
1547
+ class ResourceManagerCLI
1548
+ def initialize
1549
+ @commands = {
1550
+ 'list' => Appydave::Tools::CliActions::ResourceListAction.new,
1551
+ 'create' => Appydave::Tools::CliActions::ResourceCreateAction.new,
1552
+ 'delete' => Appydave::Tools::CliActions::ResourceDeleteAction.new
1553
+ }
1554
+ end
1555
+
1556
+ def run
1557
+ command, *args = ARGV
1558
+
1559
+ if @commands.key?(command)
1560
+ @commands[command].action(args)
1561
+ else
1562
+ puts "Unknown command: #{command}"
1563
+ print_help
1564
+ end
1565
+ end
1566
+
1567
+ private
1568
+
1569
+ def print_help
1570
+ puts 'Usage: resource_manager.rb [command] [options]'
1571
+ puts 'Commands:'
1572
+ puts ' list List resources'
1573
+ puts ' create Create a new resource'
1574
+ puts ' delete Delete a resource'
1575
+ puts "Run 'resource_manager.rb [command] --help' for more information."
1576
+ end
1577
+ end
1578
+
1579
+ ResourceManagerCLI.new.run
1580
+ ```
1581
+
1582
+ ---
1583
+
1584
+ ## Summary
1585
+
1586
+ This guide provides three proven patterns for CLI architecture in appydave-tools:
1587
+
1588
+ 1. **Pattern 1**: Single-command tools - Simple, linear execution
1589
+ 2. **Pattern 2**: Multi-command with inline routing - 2-5 commands, simple routing
1590
+ 3. **Pattern 3**: Multi-command with BaseAction - 6+ commands, shared patterns
1591
+
1592
+ **Key Principles:**
1593
+ - Separate CLI interface (`bin/`) from business logic (`lib/`)
1594
+ - No CLI code in `lib/` - business logic should be usable programmatically
1595
+ - Use `frozen_string_literal: true` in all Ruby files
1596
+ - Follow existing naming conventions
1597
+ - Test business logic, not CLI executables
1598
+ - Document with `_doc.md` files
1599
+
1600
+ When in doubt, start with **Pattern 1** or **Pattern 2** and refactor to **Pattern 3** if the tool grows to 6+ commands.
1601
+
1602
+ ---
1603
+
1604
+ **Last updated:** 2025-11-08