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.
- checksums.yaml +4 -4
- data/.rubocop.yml +6 -0
- data/AGENTS.md +22 -0
- data/CHANGELOG.md +12 -0
- data/CLAUDE.md +206 -51
- data/README.md +144 -11
- data/bin/archive_project.rb +249 -0
- data/bin/configuration.rb +21 -1
- data/bin/generate_manifest.rb +357 -0
- data/bin/sync_from_ssd.rb +236 -0
- data/bin/vat +623 -0
- data/docs/README.md +169 -0
- data/docs/configuration/.env.example +19 -0
- data/docs/configuration/README.md +394 -0
- data/docs/configuration/channels.example.json +26 -0
- data/docs/configuration/settings.example.json +6 -0
- data/docs/development/CODEX-recommendations.md +123 -0
- data/docs/development/README.md +100 -0
- data/docs/development/cli-architecture-patterns.md +1604 -0
- data/docs/development/pattern-comparison.md +284 -0
- data/docs/prd-unified-brands-configuration.md +792 -0
- data/docs/project-brand-systems-analysis.md +934 -0
- data/docs/vat/dam-vision.md +123 -0
- data/docs/vat/session-summary-2025-11-09.md +297 -0
- data/docs/vat/usage.md +508 -0
- data/docs/vat/vat-testing-plan.md +801 -0
- data/lib/appydave/tools/configuration/models/brands_config.rb +238 -0
- data/lib/appydave/tools/configuration/models/config_base.rb +7 -0
- data/lib/appydave/tools/configuration/models/settings_config.rb +4 -0
- data/lib/appydave/tools/vat/config.rb +153 -0
- data/lib/appydave/tools/vat/config_loader.rb +91 -0
- data/lib/appydave/tools/vat/manifest_generator.rb +239 -0
- data/lib/appydave/tools/vat/project_listing.rb +198 -0
- data/lib/appydave/tools/vat/project_resolver.rb +132 -0
- data/lib/appydave/tools/vat/s3_operations.rb +560 -0
- data/lib/appydave/tools/version.rb +1 -1
- data/lib/appydave/tools.rb +9 -1
- data/package.json +1 -1
- metadata +57 -3
- 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
|