humanizer-rb 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 26174e5aca0e9bd2fe7400e1fef9a27270bcbbf1f84c2141cc9d3fd6f4a6ecf3
4
+ data.tar.gz: a81821a2382d75b218dcd2f4cc31d6c68c6dcbc68460e6145bc2f7c8002ed3a3
5
+ SHA512:
6
+ metadata.gz: 0ec0e195c1451518845b46ee08a4288b6db23f23243cc4e7c6bb32152e30c4c6428eafcd910a08d468f9b310f86e26f280e128a96e9ddf0ef6e6760a8c0e58c4
7
+ data.tar.gz: 71c83a9b6fe14ac98ef978d078b14798c2c39a0fee42be3c5960b800abac5bd6bad91a53b81a752843bd4664f5fc263dbb59cb978782f0315164ef928caca331
data/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-03-16)
4
+
5
+ - Initial release — Ruby port of [humanizer](https://github.com/christiangenco/humanizer) (Node.js) v2.2.0
6
+ - 28 pattern detectors across 5 categories
7
+ - 500+ AI vocabulary terms in 3 tiers
8
+ - Statistical text analysis (burstiness, TTR, Flesch-Kincaid, trigram repetition)
9
+ - Composite scoring engine (0-100)
10
+ - CLI with `analyze`, `score`, `humanize`, `suggest`, `stats`, `report` commands
11
+ - Humanization engine with auto-fix and suggestion prioritization
12
+ - Zero runtime dependencies — pure Ruby
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Christian Genco
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,133 @@
1
+ # humanizer-rb
2
+
3
+ Detect AI-generated writing patterns. Scores text 0-100 using 28 pattern detectors, 500+ vocabulary terms, and statistical text analysis.
4
+
5
+ > **Ruby port of [humanizer](https://github.com/christiangenco/humanizer)** (Node.js). Same detection engine, same scoring algorithm, zero dependencies.
6
+
7
+ ## Installation
8
+
9
+ Add to your Gemfile:
10
+
11
+ ```ruby
12
+ gem "humanizer-rb"
13
+ ```
14
+
15
+ Or install directly:
16
+
17
+ ```sh
18
+ gem install humanizer-rb
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ ### Ruby API
24
+
25
+ ```ruby
26
+ require "humanizer"
27
+
28
+ # Quick score (0-100, higher = more AI-like)
29
+ Humanizer.score("Your text here")
30
+ # => 42
31
+
32
+ # Full analysis
33
+ result = Humanizer.analyze("Your text here")
34
+ result.score # => 42
35
+ result.pattern_score # => 48
36
+ result.uniformity_score # => 30
37
+ result.total_matches # => 7
38
+ result.word_count # => 156
39
+ result.findings # => [{ pattern_id: 7, pattern_name: "AI vocabulary", ... }]
40
+ result.categories # => { content: { matches: 2, ... }, language: { matches: 5, ... } }
41
+ result.stats # => #<Humanizer::Stats::Result burstiness: 0.31, ...>
42
+ result.summary # => "Score: 42/100 (moderately AI-influenced)..."
43
+
44
+ # Humanization suggestions with priorities
45
+ suggestions = Humanizer.humanize("Your text here")
46
+ suggestions[:critical] # Dead giveaways (weight 4-5)
47
+ suggestions[:important] # Noticeable patterns (weight 2-3)
48
+ suggestions[:minor] # Subtle tells (weight 1)
49
+ suggestions[:guidance] # Actionable writing tips
50
+ suggestions[:style_tips] # Statistical improvement suggestions
51
+
52
+ # Safe mechanical auto-fixes
53
+ fixed = Humanizer.auto_fix("In order to utilize this...")
54
+ fixed[:text] # => "to use this..."
55
+ fixed[:fixes] # => ['"in order to" → "to"', '"utilize" → "use"']
56
+ ```
57
+
58
+ ### CLI
59
+
60
+ ```sh
61
+ # Quick score
62
+ echo "This is a testament to..." | humanizer score
63
+ # => 🟡 38/100
64
+
65
+ # Full analysis
66
+ humanizer analyze essay.txt
67
+
68
+ # Markdown report
69
+ humanizer report article.txt > report.md
70
+
71
+ # Humanization suggestions
72
+ humanizer suggest article.txt
73
+
74
+ # Auto-fix + suggestions
75
+ humanizer humanize --autofix -f article.txt
76
+
77
+ # Statistical analysis only
78
+ humanizer stats essay.txt
79
+
80
+ # JSON output
81
+ humanizer analyze --json essay.txt
82
+ ```
83
+
84
+ #### Score badges
85
+
86
+ | Score | Badge | Label |
87
+ |-------|-------|-------|
88
+ | 0-25 | 🟢 | Mostly human-sounding |
89
+ | 26-50 | 🟡 | Lightly AI-touched |
90
+ | 51-75 | 🟠 | Moderately AI-influenced |
91
+ | 76-100 | 🔴 | Heavily AI-generated |
92
+
93
+ ## How it works
94
+
95
+ The score combines three signals:
96
+
97
+ 1. **Pattern matches (70%)** — 28 detectors scan for AI writing patterns across 5 categories:
98
+ - **Content**: significance inflation, promotional language, vague attributions
99
+ - **Language**: AI vocabulary (500+ words in 3 tiers), copula avoidance, synonym cycling
100
+ - **Style**: em dash overuse, boldface overuse, emoji decoration, curly quotes
101
+ - **Communication**: chatbot artifacts, sycophantic tone, reasoning chain artifacts
102
+ - **Filler**: wordy phrases, excessive hedging, generic conclusions
103
+
104
+ 2. **Statistical uniformity (30%)** — measures how "robotic" the text structure is:
105
+ - Burstiness (sentence length variation between consecutive sentences)
106
+ - Type-token ratio (vocabulary diversity)
107
+ - Trigram repetition
108
+ - Sentence length standard deviation
109
+
110
+ 3. **Category breadth** — more diverse AI signals = higher score
111
+
112
+ ## Rails integration
113
+
114
+ ```ruby
115
+ # Gemfile
116
+ gem "humanizer-rb"
117
+
118
+ # app/models/email.rb
119
+ class Email < ApplicationRecord
120
+ before_save :calculate_humanizer_score,
121
+ if: -> { body_changed? && body.present? }
122
+
123
+ private
124
+
125
+ def calculate_humanizer_score
126
+ self.humanizer_score = Humanizer.score(body)
127
+ end
128
+ end
129
+ ```
130
+
131
+ ## License
132
+
133
+ MIT — see [LICENSE](LICENSE).
data/bin/humanizer ADDED
@@ -0,0 +1,388 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/humanizer"
5
+
6
+ # ── Color Helpers ────────────────────────────────────────
7
+
8
+ SUPPORTS_COLOR = $stdout.tty? && !ENV["NO_COLOR"]
9
+
10
+ def color(code, s)
11
+ SUPPORTS_COLOR ? "\e[#{code}m#{s}\e[0m" : s
12
+ end
13
+
14
+ def red(s) = color(31, s)
15
+ def green(s) = color(32, s)
16
+ def yellow(s) = color(33, s)
17
+ def cyan(s) = color(36, s)
18
+ def magenta(s) = color(35, s)
19
+ def bold(s) = color(1, s)
20
+ def dim(s) = color(2, s)
21
+
22
+ def score_badge(s)
23
+ if s <= 25 then green("🟢 #{s}/100")
24
+ elsif s <= 50 then yellow("🟡 #{s}/100")
25
+ elsif s <= 75 then magenta("🟠 #{s}/100")
26
+ else red("🔴 #{s}/100")
27
+ end
28
+ end
29
+
30
+ def score_label(s)
31
+ if s <= 19 then "Mostly human-sounding"
32
+ elsif s <= 44 then "Lightly AI-touched"
33
+ elsif s <= 69 then "Moderately AI-influenced"
34
+ else "Heavily AI-generated"
35
+ end
36
+ end
37
+
38
+ def burstiness_label(b)
39
+ if b >= 0.7 then green("(high — human-like)")
40
+ elsif b >= 0.45 then yellow("(moderate)")
41
+ elsif b >= 0.25 then yellow("(low — somewhat uniform)")
42
+ else red("(very low — AI-like)")
43
+ end
44
+ end
45
+
46
+ def ttr_label(ttr, wc)
47
+ if wc < 100 then dim("(too short to assess)")
48
+ elsif ttr >= 0.6 then green("(high — diverse)")
49
+ elsif ttr >= 0.45 then yellow("(moderate)")
50
+ else red("(low — repetitive)")
51
+ end
52
+ end
53
+
54
+ def truncate(str, len)
55
+ return "" unless str.is_a?(String)
56
+ str.length > len ? "#{str[0, len]}..." : str
57
+ end
58
+
59
+ # ── Arg Parsing ──────────────────────────────────────────
60
+
61
+ args = ARGV.dup
62
+
63
+ # Handle top-level --help before command parsing
64
+ if args.include?("--help") || args.include?("-h")
65
+ args.delete("--help")
66
+ args.delete("-h")
67
+ end
68
+
69
+ command = args.shift
70
+
71
+ flags = {
72
+ json: args.delete("--json"),
73
+ verbose: args.delete("--verbose") || args.delete("-v"),
74
+ autofix: args.delete("--autofix"),
75
+ help: ARGV.include?("--help") || ARGV.include?("-h") || command.nil?,
76
+ file: nil,
77
+ patterns: nil,
78
+ threshold: nil,
79
+ }
80
+
81
+ # Parse -f / --file
82
+ if (idx = args.index("-f") || args.index("--file"))
83
+ flags[:file] = args[idx + 1]
84
+ args.slice!(idx, 2)
85
+ end
86
+
87
+ # Parse --patterns
88
+ if (idx = args.index("--patterns"))
89
+ flags[:patterns] = args[idx + 1]&.split(",")&.map(&:to_i)&.select { |n| n > 0 }
90
+ args.slice!(idx, 2)
91
+ end
92
+
93
+ # Parse --threshold
94
+ if (idx = args.index("--threshold"))
95
+ flags[:threshold] = args[idx + 1]&.to_i
96
+ args.slice!(idx, 2)
97
+ end
98
+
99
+ # Positional file argument
100
+ flags[:file] ||= args.first unless args.first&.start_with?("-")
101
+
102
+ # ── Help ─────────────────────────────────────────────────
103
+
104
+ HELP = <<~HELP
105
+ #{bold('humanizer')} — Detect and remove AI writing patterns
106
+
107
+ #{bold('Usage:')}
108
+ humanizer <command> [file] [options]
109
+
110
+ #{bold('Commands:')}
111
+ #{cyan('analyze')} Full analysis report with pattern matches
112
+ #{cyan('score')} Quick score (0-100, higher = more AI-like)
113
+ #{cyan('humanize')} Humanization suggestions with guidance
114
+ #{cyan('report')} Full markdown report (for piping to files)
115
+ #{cyan('suggest')} Show only suggestions, grouped by priority
116
+ #{cyan('stats')} Show statistical text analysis only
117
+
118
+ #{bold('Options:')}
119
+ -f, --file <path> Read text from file (otherwise reads stdin)
120
+ --json Output as JSON
121
+ --verbose, -v Show all matches (not just top 5 per pattern)
122
+ --autofix Apply safe mechanical fixes (humanize only)
123
+ --patterns <ids> Only check specific pattern IDs (comma-separated)
124
+ --threshold <n> Only show patterns with weight above threshold
125
+ --help, -h Show this help
126
+
127
+ #{bold('Examples:')}
128
+ #{dim('# Quick score')}
129
+ echo "This is a testament to..." | humanizer score
130
+
131
+ #{dim('# Analyze a file')}
132
+ humanizer analyze essay.txt
133
+
134
+ #{dim('# Full markdown report')}
135
+ humanizer report article.txt > report.md
136
+
137
+ #{dim('# Humanize with auto-fixes')}
138
+ humanizer humanize --autofix -f article.txt
139
+
140
+ #{bold('Score badges:')}
141
+ 🟢 0-25 Mostly human-sounding
142
+ 🟡 26-50 Lightly AI-touched
143
+ 🟠 51-75 Moderately AI-influenced
144
+ 🔴 76-100 Heavily AI-generated
145
+ HELP
146
+
147
+ # ── Read Input ───────────────────────────────────────────
148
+
149
+ def read_input(flags)
150
+ if flags[:file]
151
+ File.read(flags[:file])
152
+ elsif !$stdin.tty?
153
+ $stdin.read
154
+ else
155
+ $stderr.puts red("Error: No input. Pipe text or use -f <file>. Run with --help for usage.")
156
+ exit 1
157
+ end
158
+ rescue Errno::ENOENT => e
159
+ $stderr.puts red("Error: #{e.message}")
160
+ exit 1
161
+ end
162
+
163
+ # ── Formatters ───────────────────────────────────────────
164
+
165
+ def format_colored_report(result, threshold: nil)
166
+ lines = []
167
+ lines << ""
168
+ lines << bold(" ┌──────────────────────────────────────────────┐")
169
+ lines << bold(" │ AI WRITING PATTERN ANALYSIS │")
170
+ lines << bold(" └──────────────────────────────────────────────┘")
171
+ lines << ""
172
+
173
+ filled = (result.score / 5.0).round
174
+ bar_color_fn = result.score <= 25 ? method(:green) : result.score <= 50 ? method(:yellow) : result.score <= 75 ? method(:magenta) : method(:red)
175
+ bar = bar_color_fn.call("█" * filled) + dim("░" * (20 - filled))
176
+ lines << " Score: #{score_badge(result.score)} [#{bar}]"
177
+ lines << " #{dim("Words: #{result.word_count} | Matches: #{result.total_matches} | Pattern: #{result.pattern_score} | Uniformity: #{result.uniformity_score}")}"
178
+ lines << ""
179
+ lines << " #{result.summary}"
180
+ lines << ""
181
+
182
+ if result.stats
183
+ s = result.stats
184
+ lines << bold(" ── Statistics ──────────────────────────────────")
185
+ lines << " Burstiness: #{s.burstiness} #{burstiness_label(s.burstiness)}"
186
+ lines << " Type-token ratio: #{s.type_token_ratio} #{ttr_label(s.type_token_ratio, s.word_count)}"
187
+ lines << " Trigram repetition: #{s.trigram_repetition}"
188
+ lines << " Readability: #{s.flesch_kincaid} grade level"
189
+ lines << ""
190
+ end
191
+
192
+ lines << bold(" ── Categories ──────────────────────────────────")
193
+ result.categories.each do |_, data|
194
+ if data[:matches] > 0
195
+ lines << " #{cyan(data[:label])}: #{data[:matches]} matches #{dim("(#{data[:patterns_detected].join(', ')})")}"
196
+ end
197
+ end
198
+ lines << ""
199
+
200
+ if result.findings.any?
201
+ lines << bold(" ── Findings ──────────────────────────────────")
202
+ result.findings.each do |finding|
203
+ next if threshold && finding[:weight] < threshold
204
+
205
+ weight_color = finding[:weight] >= 4 ? method(:red) : finding[:weight] >= 2 ? method(:yellow) : method(:cyan)
206
+ lines << ""
207
+ lines << " #{weight_color.call("[#{finding[:pattern_id]}]")} #{bold(finding[:pattern_name])} #{dim("(×#{finding[:match_count]}, weight: #{finding[:weight]})")}"
208
+ lines << " #{dim(finding[:description])}"
209
+ finding[:matches].each do |match|
210
+ loc = match[:line] ? "L#{match[:line]}" : ""
211
+ preview = match[:match].is_a?(String) ? match[:match][0, 80] : ""
212
+ lines << " #{dim(loc)}: \"#{preview}\""
213
+ lines << " #{green('→')} #{match[:suggestion]}" if match[:suggestion]
214
+ end
215
+ if finding[:truncated]
216
+ lines << " #{dim("... and #{finding[:match_count] - finding[:matches].length} more")}"
217
+ end
218
+ end
219
+ end
220
+
221
+ lines << ""
222
+ lines << dim(" ──────────────────────────────────────────────")
223
+ lines.join("\n")
224
+ end
225
+
226
+ def format_stats_report(stats)
227
+ lines = []
228
+ lines << ""
229
+ lines << bold(" ┌──────────────────────────────────────────────┐")
230
+ lines << bold(" │ TEXT STATISTICS ANALYSIS │")
231
+ lines << bold(" └──────────────────────────────────────────────┘")
232
+ lines << ""
233
+ lines << bold(" ── Sentences ──────────────────────────────────")
234
+ lines << " Count: #{stats.sentence_count}"
235
+ lines << " Avg length: #{stats.avg_sentence_length} words"
236
+ lines << " Std deviation: #{stats.sentence_length_std_dev}"
237
+ lines << " Burstiness: #{stats.burstiness} #{burstiness_label(stats.burstiness)}"
238
+ lines << ""
239
+ lines << bold(" ── Vocabulary ─────────────────────────────────")
240
+ lines << " Total words: #{stats.word_count}"
241
+ lines << " Unique words: #{stats.unique_word_count}"
242
+ lines << " Type-token ratio: #{stats.type_token_ratio} #{ttr_label(stats.type_token_ratio, stats.word_count)}"
243
+ lines << " Avg word length: #{stats.avg_word_length}"
244
+ lines << ""
245
+ lines << bold(" ── Structure ──────────────────────────────────")
246
+ lines << " Paragraphs: #{stats.paragraph_count}"
247
+ lines << " Avg para length: #{stats.avg_paragraph_length} words"
248
+ lines << " Trigram repeat: #{stats.trigram_repetition}"
249
+ lines << ""
250
+ lines << bold(" ── Readability ────────────────────────────────")
251
+ lines << " Flesch-Kincaid: #{stats.flesch_kincaid} grade level"
252
+ lines << " Function words: #{stats.function_word_ratio} (#{(stats.function_word_ratio * 100).round(1)}%)"
253
+ lines << ""
254
+ lines.join("\n")
255
+ end
256
+
257
+ def format_grouped_suggestions(result)
258
+ lines = []
259
+ lines << ""
260
+ lines << bold(" Score: #{score_badge(result[:score])} (#{score_label(result[:score])})")
261
+ lines << " #{dim("#{result[:total_issues]} issues found in #{result[:word_count]} words")}"
262
+ lines << ""
263
+
264
+ if result[:critical].any?
265
+ lines << red(bold(" ━━ CRITICAL (remove these first) ━━━━━━━━━━━━"))
266
+ result[:critical].each do |s|
267
+ lines << " #{red('●')} L#{s[:line]}: #{bold(s[:pattern])}"
268
+ lines << " #{dim(truncate(s[:text], 60))}"
269
+ lines << " #{green('→')} #{s[:suggestion]}"
270
+ end
271
+ lines << ""
272
+ end
273
+
274
+ if result[:important].any?
275
+ lines << yellow(bold(" ━━ IMPORTANT (noticeable AI patterns) ━━━━━━━"))
276
+ result[:important].each do |s|
277
+ lines << " #{yellow('●')} L#{s[:line]}: #{bold(s[:pattern])}"
278
+ lines << " #{dim(truncate(s[:text], 60))}"
279
+ lines << " #{green('→')} #{s[:suggestion]}"
280
+ end
281
+ lines << ""
282
+ end
283
+
284
+ if result[:minor].any?
285
+ lines << cyan(bold(" ━━ MINOR (subtle tells) ━━━━━━━━━━━━━━━━━━━━"))
286
+ result[:minor].each do |s|
287
+ lines << " #{cyan('●')} L#{s[:line]}: #{bold(s[:pattern])}"
288
+ lines << " #{dim(truncate(s[:text], 60))}"
289
+ lines << " #{green('→')} #{s[:suggestion]}"
290
+ end
291
+ lines << ""
292
+ end
293
+
294
+ if result[:guidance].any?
295
+ lines << cyan(bold(" ━━ GUIDANCE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"))
296
+ result[:guidance].each { |tip| lines << " #{cyan('•')} #{tip}" }
297
+ lines << ""
298
+ end
299
+
300
+ if result[:style_tips]&.any?
301
+ lines << magenta(bold(" ━━ STYLE TIPS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"))
302
+ result[:style_tips].each { |t| lines << " #{magenta('◦')} #{t[:tip]}" }
303
+ lines << ""
304
+ end
305
+
306
+ lines.join("\n")
307
+ end
308
+
309
+ # ── Main ─────────────────────────────────────────────────
310
+
311
+ if flags[:help] || command.nil?
312
+ puts HELP
313
+ exit(command ? 0 : 1)
314
+ end
315
+
316
+ text = read_input(flags)
317
+ if text.strip.empty?
318
+ $stderr.puts red("Error: Empty input.")
319
+ exit 1
320
+ end
321
+
322
+ opts = {
323
+ verbose: !!flags[:verbose],
324
+ patterns_to_check: flags[:patterns],
325
+ }
326
+
327
+ case command
328
+ when "analyze"
329
+ result = Humanizer.analyze(text, **opts)
330
+ if flags[:json]
331
+ puts Humanizer::Analyzer.format_json(result)
332
+ else
333
+ puts format_colored_report(result, threshold: flags[:threshold])
334
+ end
335
+
336
+ when "score"
337
+ s = Humanizer.score(text)
338
+ if flags[:json]
339
+ require "json"
340
+ puts JSON.generate({ score: s })
341
+ else
342
+ puts score_badge(s)
343
+ end
344
+
345
+ when "humanize"
346
+ result = Humanizer.humanize(text, autofix: !!flags[:autofix], verbose: !!flags[:verbose])
347
+ if flags[:json]
348
+ require "json"
349
+ puts JSON.pretty_generate(result)
350
+ else
351
+ # Simple formatted output
352
+ puts format_grouped_suggestions(result)
353
+ if flags[:autofix] && result[:autofix]
354
+ puts ""
355
+ puts bold("── AUTO-FIXED TEXT ──────────────────────────────")
356
+ puts ""
357
+ puts result[:autofix][:text]
358
+ puts ""
359
+ puts dim("════════════════════════════════════════════════")
360
+ end
361
+ end
362
+
363
+ when "report"
364
+ result = Humanizer.analyze(text, verbose: true, patterns_to_check: flags[:patterns])
365
+ puts Humanizer::Analyzer.format_markdown(result)
366
+
367
+ when "suggest"
368
+ result = Humanizer.humanize(text, verbose: !!flags[:verbose])
369
+ if flags[:json]
370
+ require "json"
371
+ puts JSON.pretty_generate(result)
372
+ else
373
+ puts format_grouped_suggestions(result)
374
+ end
375
+
376
+ when "stats"
377
+ stats = Humanizer::Stats.compute(text)
378
+ if flags[:json]
379
+ require "json"
380
+ puts JSON.pretty_generate(stats.to_h)
381
+ else
382
+ puts format_stats_report(stats)
383
+ end
384
+
385
+ else
386
+ $stderr.puts red("Unknown command: #{command}. Run with --help for usage.")
387
+ exit 1
388
+ end