claude_usage 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,298 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Claude Code Usage Report</title>
5
+ <style>
6
+ * {
7
+ margin: 0;
8
+ padding: 0;
9
+ box-sizing: border-box;
10
+ }
11
+
12
+ body {
13
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
14
+ background: #f9fafb;
15
+ min-height: 100vh;
16
+ padding: 2rem;
17
+ }
18
+
19
+ h1 {
20
+ color: #111827;
21
+ text-align: center;
22
+ margin-bottom: 2rem;
23
+ font-size: 2.5rem;
24
+ }
25
+
26
+ .container {
27
+ max-width: 1200px;
28
+ margin: 0 auto;
29
+ }
30
+
31
+ .error {
32
+ background: #fee;
33
+ border: 2px solid #f66;
34
+ color: #c33;
35
+ padding: 1.5rem;
36
+ border-radius: 8px;
37
+ margin-bottom: 2rem;
38
+ }
39
+
40
+ .totals-grid {
41
+ display: grid;
42
+ grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
43
+ gap: 1.5rem;
44
+ margin-bottom: 2rem;
45
+ }
46
+
47
+ .stat-card {
48
+ background: white;
49
+ padding: 1.5rem;
50
+ border-radius: 12px;
51
+ box-shadow: 0 4px 6px rgba(0,0,0,0.1);
52
+ transition: transform 0.2s;
53
+ }
54
+
55
+ .stat-card:hover {
56
+ transform: translateY(-4px);
57
+ box-shadow: 0 8px 12px rgba(0,0,0,0.15);
58
+ }
59
+
60
+ .stat-label {
61
+ font-size: 0.875rem;
62
+ color: #666;
63
+ text-transform: uppercase;
64
+ letter-spacing: 0.5px;
65
+ margin-bottom: 0.5rem;
66
+ }
67
+
68
+ .stat-value {
69
+ font-size: 2rem;
70
+ font-weight: bold;
71
+ color: #667eea;
72
+ }
73
+
74
+ .stat-value.cost {
75
+ color: #10b981;
76
+ }
77
+
78
+ table {
79
+ width: 100%;
80
+ background: white;
81
+ border-radius: 12px;
82
+ overflow: hidden;
83
+ box-shadow: 0 4px 6px rgba(0,0,0,0.1);
84
+ border-collapse: collapse;
85
+ }
86
+
87
+ thead {
88
+ background: #374151;
89
+ color: white;
90
+ }
91
+
92
+ th {
93
+ padding: 1rem;
94
+ text-align: left;
95
+ font-weight: 600;
96
+ text-transform: uppercase;
97
+ font-size: 0.875rem;
98
+ letter-spacing: 0.5px;
99
+ }
100
+
101
+ td {
102
+ padding: 1rem;
103
+ border-bottom: 1px solid #e5e7eb;
104
+ }
105
+
106
+ tbody tr:last-child td {
107
+ border-bottom: none;
108
+ }
109
+
110
+ tbody tr:hover {
111
+ background: #f9fafb;
112
+ }
113
+
114
+ .date-cell {
115
+ font-weight: 600;
116
+ color: #374151;
117
+ }
118
+
119
+ .number-cell {
120
+ text-align: right;
121
+ font-variant-numeric: tabular-nums;
122
+ }
123
+
124
+ .cost-cell {
125
+ text-align: right;
126
+ font-weight: bold;
127
+ color: #10b981;
128
+ font-variant-numeric: tabular-nums;
129
+ }
130
+
131
+ .model-badge {
132
+ display: inline-block;
133
+ background: #e0e7ff;
134
+ color: #4338ca;
135
+ padding: 0.25rem 0.75rem;
136
+ border-radius: 9999px;
137
+ font-size: 0.75rem;
138
+ font-weight: 500;
139
+ margin: 0.125rem;
140
+ }
141
+
142
+ .footer {
143
+ text-align: center;
144
+ color: #6b7280;
145
+ margin-top: 2rem;
146
+ font-size: 0.875rem;
147
+ }
148
+
149
+ .footer a {
150
+ color: #4f46e5;
151
+ text-decoration: underline;
152
+ }
153
+
154
+ .api-link {
155
+ display: inline-block;
156
+ background: #4f46e5;
157
+ color: white;
158
+ padding: 0.75rem 1.5rem;
159
+ border-radius: 8px;
160
+ text-decoration: none;
161
+ font-weight: 600;
162
+ margin: 1rem auto;
163
+ display: block;
164
+ text-align: center;
165
+ max-width: 200px;
166
+ transition: all 0.2s;
167
+ }
168
+
169
+ .api-link:hover {
170
+ background: #4338ca;
171
+ transform: translateY(-2px);
172
+ box-shadow: 0 4px 6px rgba(0,0,0,0.1);
173
+ }
174
+
175
+ @media (max-width: 768px) {
176
+ body {
177
+ padding: 1rem;
178
+ }
179
+
180
+ h1 {
181
+ font-size: 1.75rem;
182
+ }
183
+
184
+ .totals-grid {
185
+ grid-template-columns: 1fr;
186
+ }
187
+
188
+ table {
189
+ font-size: 0.875rem;
190
+ }
191
+
192
+ th, td {
193
+ padding: 0.75rem 0.5rem;
194
+ }
195
+ }
196
+ </style>
197
+ </head>
198
+ <body>
199
+ <div class="container">
200
+ <h1>Claude Code Usage Report</h1>
201
+
202
+ <% if @error %>
203
+ <div class="error">
204
+ <strong>Error:</strong> <%= @error %>
205
+ </div>
206
+ <% else %>
207
+ <div class="totals-grid">
208
+ <div class="stat-card">
209
+ <div class="stat-label">Total Cost</div>
210
+ <div class="stat-value cost">$<%= sprintf('%.4f', @totals[:total_cost] || 0) %></div>
211
+ </div>
212
+
213
+ <div class="stat-card">
214
+ <div class="stat-label">Total Tokens</div>
215
+ <div class="stat-value"><%= format_number(@totals[:total_tokens] || 0) %></div>
216
+ </div>
217
+
218
+ <div class="stat-card">
219
+ <div class="stat-label">Input Tokens</div>
220
+ <div class="stat-value"><%= format_number(@totals[:total_input_tokens] || 0) %></div>
221
+ </div>
222
+
223
+ <div class="stat-card">
224
+ <div class="stat-label">Output Tokens</div>
225
+ <div class="stat-value"><%= format_number(@totals[:total_output_tokens] || 0) %></div>
226
+ </div>
227
+
228
+ <div class="stat-card">
229
+ <div class="stat-label">Cache Creation</div>
230
+ <div class="stat-value"><%= format_number(@totals[:total_cache_creation_tokens] || 0) %></div>
231
+ </div>
232
+
233
+ <div class="stat-card">
234
+ <div class="stat-label">Cache Read</div>
235
+ <div class="stat-value"><%= format_number(@totals[:total_cache_read_tokens] || 0) %></div>
236
+ </div>
237
+
238
+ <div class="stat-card">
239
+ <div class="stat-label">Days Active</div>
240
+ <div class="stat-value"><%= @totals[:days_active] || 0 %></div>
241
+ </div>
242
+
243
+ <div class="stat-card">
244
+ <div class="stat-label">Models Used</div>
245
+ <div class="stat-value"><%= (@totals[:models_used] || []).size %></div>
246
+ </div>
247
+ </div>
248
+
249
+ <% if @daily_usage.any? %>
250
+ <table>
251
+ <thead>
252
+ <tr>
253
+ <th>Date</th>
254
+ <th style="text-align: right;">Input</th>
255
+ <th style="text-align: right;">Output</th>
256
+ <th style="text-align: right;">Cache Create</th>
257
+ <th style="text-align: right;">Cache Read</th>
258
+ <th style="text-align: right;">Total Tokens</th>
259
+ <th style="text-align: right;">Cost</th>
260
+ <th style="text-align: right;">Requests</th>
261
+ <th>Models</th>
262
+ </tr>
263
+ </thead>
264
+ <tbody>
265
+ <% @daily_usage.each do |day| %>
266
+ <tr>
267
+ <td class="date-cell"><%= day[:date].strftime('%Y-%m-%d') %></td>
268
+ <td class="number-cell"><%= format_number(day[:input_tokens]) %></td>
269
+ <td class="number-cell"><%= format_number(day[:output_tokens]) %></td>
270
+ <td class="number-cell"><%= format_number(day[:cache_creation_tokens]) %></td>
271
+ <td class="number-cell"><%= format_number(day[:cache_read_tokens]) %></td>
272
+ <td class="number-cell"><%= format_number(day[:total_tokens]) %></td>
273
+ <td class="cost-cell">$<%= sprintf('%.4f', day[:cost]) %></td>
274
+ <td class="number-cell"><%= day[:request_count] %></td>
275
+ <td>
276
+ <% day[:models_used].each do |model| %>
277
+ <span class="model-badge"><%= model %></span>
278
+ <% end %>
279
+ </td>
280
+ </tr>
281
+ <% end %>
282
+ </tbody>
283
+ </table>
284
+ <% else %>
285
+ <div class="error">
286
+ <strong>No usage data found.</strong> Make sure you have Claude Code usage logs at ~/.config/claude/projects/
287
+ </div>
288
+ <% end %>
289
+
290
+ <a href="<%= claude_usage_engine.json_path %>" class="api-link">📊 View JSON API</a>
291
+
292
+ <div class="footer">
293
+ <p>Powered by <strong>claude_usage</strong> gem | Pricing from <a href="https://github.com/BerriAI/litellm" target="_blank">LiteLLM</a></p>
294
+ </div>
295
+ <% end %>
296
+ </div>
297
+ </body>
298
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ ClaudeUsage::Engine.routes.draw do
4
+ root to: 'usage#index'
5
+ get 'json', to: 'usage#show_json', as: :json
6
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeUsage
4
+ class CostCalculator
5
+ TIERED_THRESHOLD = 200_000
6
+
7
+ def initialize
8
+ @pricing_fetcher = LiteLLMPricingFetcher.new
9
+ end
10
+
11
+ def calculate_cost(entry)
12
+ return entry[:pre_calculated_cost] if entry[:pre_calculated_cost]
13
+
14
+ # Skip if no model information
15
+ return 0 if entry[:model].nil? || entry[:model].empty?
16
+
17
+ model_pricing = @pricing_fetcher.get_model_pricing(entry[:model])
18
+ return 0 unless model_pricing
19
+
20
+ calculate_tiered_cost(
21
+ entry[:input_tokens],
22
+ model_pricing[:input_cost_per_token],
23
+ model_pricing[:input_cost_per_token_above_200k]
24
+ ) +
25
+ calculate_tiered_cost(
26
+ entry[:output_tokens],
27
+ model_pricing[:output_cost_per_token],
28
+ model_pricing[:output_cost_per_token_above_200k]
29
+ ) +
30
+ calculate_tiered_cost(
31
+ entry[:cache_creation_tokens],
32
+ model_pricing[:cache_creation_input_token_cost],
33
+ model_pricing[:cache_creation_cost_above_200k]
34
+ ) +
35
+ calculate_tiered_cost(
36
+ entry[:cache_read_tokens],
37
+ model_pricing[:cache_read_input_token_cost],
38
+ model_pricing[:cache_read_cost_above_200k]
39
+ )
40
+ end
41
+
42
+ private
43
+
44
+ def calculate_tiered_cost(tokens, base_price, tiered_price)
45
+ return 0 if tokens.nil? || tokens <= 0 || base_price.nil?
46
+
47
+ if tokens > TIERED_THRESHOLD && tiered_price
48
+ tokens_below = [tokens, TIERED_THRESHOLD].min
49
+ tokens_above = [0, tokens - TIERED_THRESHOLD].max
50
+
51
+ (tokens_below * base_price) + (tokens_above * tiered_price)
52
+ else
53
+ tokens * base_price
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeUsage
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace ClaudeUsage
6
+
7
+ config.generators do |g|
8
+ g.test_framework :rspec
9
+ end
10
+
11
+ # Auto-mount routes in host app
12
+ initializer 'claude_usage.routes' do |app|
13
+ app.routes.append do
14
+ mount ClaudeUsage::Engine => '/claude-usage', as: :claude_usage_engine
15
+ end
16
+ end
17
+
18
+ # Add view paths
19
+ initializer 'claude_usage.assets' do |app|
20
+ app.config.assets.paths << root.join('app/assets')
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module ClaudeUsage
6
+ class FileReader
7
+ class InvalidProjectNameError < StandardError; end
8
+
9
+ CLAUDE_PATHS = [
10
+ File.expand_path('~/.config/claude/projects'),
11
+ File.expand_path('~/.claude/projects')
12
+ ].freeze
13
+
14
+ def initialize(project_name: nil)
15
+ # Use provided name or auto-detect from Rails.root
16
+ @project_name = project_name || ClaudeUsage.configuration.resolved_project_name
17
+ validate_project_name!
18
+ end
19
+
20
+ def read_usage_data
21
+ entries = []
22
+ processed_hashes = Set.new
23
+
24
+ jsonl_files.each do |file|
25
+ File.foreach(file) do |line|
26
+ next if line.strip.empty?
27
+
28
+ begin
29
+ data = JSON.parse(line)
30
+
31
+ # Deduplication
32
+ message_id = data.dig('message', 'id')
33
+ request_id = data['requestId']
34
+
35
+ if message_id && request_id
36
+ unique_hash = "#{message_id}:#{request_id}"
37
+ next if processed_hashes.include?(unique_hash)
38
+
39
+ processed_hashes.add(unique_hash)
40
+ end
41
+
42
+ entries << parse_entry(data)
43
+ rescue JSON::ParserError => e
44
+ logger&.debug "Skipping invalid JSON line: #{e.message}"
45
+ end
46
+ end
47
+ end
48
+
49
+ entries
50
+ end
51
+
52
+ private
53
+
54
+ def validate_project_name!
55
+ return if @project_name.nil? # Allow nil for cases where no project is configured
56
+
57
+ # Prevent path traversal attacks by validating project name format
58
+ # Allow alphanumeric, dashes, underscores, and dots (for typical project names)
59
+ unless @project_name.match?(/\A[\w.-]+\z/)
60
+ raise InvalidProjectNameError,
61
+ "Invalid project name '#{@project_name}'. Only alphanumeric characters, " \
62
+ 'dashes, underscores, and dots are allowed.'
63
+ end
64
+
65
+ # Prevent path traversal patterns
66
+ return unless @project_name.include?('..') || @project_name.start_with?('/')
67
+
68
+ raise InvalidProjectNameError,
69
+ "Invalid project name '#{@project_name}'. Path traversal patterns are not allowed."
70
+ end
71
+
72
+ def claude_paths
73
+ ClaudeUsage.configuration.claude_paths || CLAUDE_PATHS
74
+ end
75
+
76
+ def jsonl_files
77
+ files = []
78
+ claude_paths.each do |base_path|
79
+ next unless Dir.exist?(base_path)
80
+
81
+ project_dir = File.join(base_path, @project_name)
82
+ next unless Dir.exist?(project_dir)
83
+
84
+ files.concat(Dir.glob(File.join(project_dir, '**', '*.jsonl')))
85
+ end
86
+ files.sort_by { |f| File.mtime(f) }
87
+ end
88
+
89
+ def parse_entry(data)
90
+ # Handle missing or invalid timestamp
91
+ timestamp = begin
92
+ data['timestamp'] ? Time.parse(data['timestamp']) : Time.now
93
+ rescue ArgumentError
94
+ Time.now
95
+ end
96
+
97
+ {
98
+ timestamp: timestamp,
99
+ model: data.dig('message', 'model'),
100
+ input_tokens: data.dig('message', 'usage', 'input_tokens') || 0,
101
+ output_tokens: data.dig('message', 'usage', 'output_tokens') || 0,
102
+ cache_creation_tokens: data.dig('message', 'usage', 'cache_creation_input_tokens') || 0,
103
+ cache_read_tokens: data.dig('message', 'usage', 'cache_read_input_tokens') || 0,
104
+ pre_calculated_cost: data['costUSD']
105
+ }
106
+ end
107
+
108
+ def logger
109
+ defined?(Rails) ? Rails.logger : nil
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeUsage
4
+ # Shared formatting utilities for displaying usage data
5
+ module Formatters
6
+ module_function
7
+
8
+ # Format a number with comma separators
9
+ # Example: 1234567 => "1,234,567"
10
+ def format_number(number)
11
+ return '0' if number.nil? || number.zero?
12
+
13
+ number.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
14
+ end
15
+
16
+ # Format a dollar amount with 2 decimal places
17
+ # Example: 1.234 => "$1.23"
18
+ def format_currency(amount)
19
+ return '$0.00' if amount.nil? || amount.zero?
20
+
21
+ format('$%.2f', amount)
22
+ end
23
+
24
+ # Format a percentage with 1 decimal place
25
+ # Example: 0.1234 => "12.3%"
26
+ def format_percentage(decimal)
27
+ return '0.0%' if decimal.nil? || decimal.zero?
28
+
29
+ format('%.1f%%', decimal * 100)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'httparty'
4
+ require 'json'
5
+
6
+ module ClaudeUsage
7
+ class LiteLLMPricingFetcher
8
+ include HTTParty
9
+
10
+ LITELLM_PRICING_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json'
11
+
12
+ def initialize(offline: nil)
13
+ @offline = offline.nil? ? ClaudeUsage.configuration.offline_mode : offline
14
+ end
15
+
16
+ def fetch_pricing_data
17
+ cache_key = 'claude_usage/litellm_pricing_data'
18
+ cache_expiry = ClaudeUsage.configuration.cache_expiry
19
+
20
+ return Rails.cache.read(cache_key) || {} if @offline
21
+
22
+ Rails.cache.fetch(cache_key, expires_in: cache_expiry) do
23
+ fetch_from_litellm
24
+ end
25
+ end
26
+
27
+ def get_model_pricing(model_name, debug: false)
28
+ return nil if model_name.nil? || model_name.empty?
29
+
30
+ pricing_data = fetch_pricing_data
31
+ debug_info = { tried: [], fallbacks: [] } if debug
32
+
33
+ # Direct match (this should work for claude-haiku-4-5-20251001, claude-sonnet-4-5-20250929)
34
+ if pricing_data.key?(model_name)
35
+ debug_info[:tried] << { candidate: model_name, found: true } if debug
36
+ return debug ? [pricing_data[model_name], debug_info] : pricing_data[model_name]
37
+ end
38
+ debug_info[:tried] << { candidate: model_name, found: false } if debug
39
+
40
+ # Try with anthropic/ prefix
41
+ anthropic_model = "anthropic/#{model_name}"
42
+ debug_info[:tried] << { candidate: anthropic_model, found: false } if debug
43
+ if pricing_data.key?(anthropic_model)
44
+ debug_info[:tried].last[:found] = true if debug
45
+ return debug ? [pricing_data[anthropic_model], debug_info] : pricing_data[anthropic_model]
46
+ end
47
+
48
+ # For fallbacks, only try if it's a known Claude 4.5 model that might not be in LiteLLM yet
49
+ if model_name.include?('haiku-4-5')
50
+ fallback_models = [
51
+ 'claude-3-5-haiku-20241022',
52
+ 'anthropic/claude-3-5-haiku-20241022',
53
+ 'claude-3-haiku-20240307',
54
+ 'anthropic/claude-3-haiku-20240307'
55
+ ]
56
+ fallback_models.each do |fallback|
57
+ debug_info[:fallbacks] << { candidate: fallback, found: false } if debug
58
+ if pricing_data.key?(fallback)
59
+ debug_info[:fallbacks].last[:found] = true if debug
60
+ return debug ? [pricing_data[fallback], debug_info] : pricing_data[fallback]
61
+ end
62
+ end
63
+ elsif model_name.include?('sonnet-4-5')
64
+ fallback_models = [
65
+ 'claude-sonnet-4-5-20250929',
66
+ 'claude-3-5-sonnet-20241022',
67
+ 'anthropic/claude-3-5-sonnet-20241022'
68
+ ]
69
+ fallback_models.each do |fallback|
70
+ debug_info[:fallbacks] << { candidate: fallback, found: false } if debug
71
+ if pricing_data.key?(fallback)
72
+ debug_info[:fallbacks].last[:found] = true if debug
73
+ return debug ? [pricing_data[fallback], debug_info] : pricing_data[fallback]
74
+ end
75
+ end
76
+ end
77
+
78
+ debug ? [nil, debug_info] : nil
79
+ end
80
+
81
+ private
82
+
83
+ def fetch_from_litellm
84
+ logger.info 'Fetching pricing from LiteLLM...'
85
+
86
+ # HTTParty configuration with configurable SSL verification
87
+ # Note: verify_ssl defaults to false for macOS compatibility
88
+ # Users can enable it via: config.verify_ssl = true
89
+ options = {
90
+ timeout: 30,
91
+ verify: ClaudeUsage.configuration.verify_ssl
92
+ }
93
+
94
+ response = self.class.get(LITELLM_PRICING_URL, options)
95
+
96
+ if response.success?
97
+ # Manually parse JSON to ensure we get a hash
98
+ raw_data = JSON.parse(response.body)
99
+ parsed_data = parse_pricing_data(raw_data)
100
+ logger.info "Successfully fetched pricing for #{parsed_data.keys.size} models"
101
+
102
+ # Log if our specific models are found
103
+ ['claude-haiku-4-5-20251001', 'claude-sonnet-4-5-20250929'].each do |model|
104
+ if parsed_data.key?(model)
105
+ logger.info "✅ Found pricing for #{model}"
106
+ else
107
+ logger.warn "❌ Missing pricing for #{model}"
108
+ end
109
+ end
110
+
111
+ parsed_data
112
+ else
113
+ logger.error "Failed to fetch LiteLLM pricing: HTTP #{response.code}"
114
+ {}
115
+ end
116
+ rescue StandardError => e
117
+ logger.error "Error fetching LiteLLM pricing: #{e.class} - #{e.message}"
118
+ logger.error e.backtrace.first(5).join("\n")
119
+ {}
120
+ end
121
+
122
+ def parse_pricing_data(raw_data)
123
+ pricing = {}
124
+
125
+ raw_data.each do |model_name, model_data|
126
+ next unless model_data.is_a?(Hash)
127
+
128
+ pricing[model_name] = {
129
+ input_cost_per_token: model_data['input_cost_per_token'],
130
+ output_cost_per_token: model_data['output_cost_per_token'],
131
+ cache_creation_input_token_cost: model_data['cache_creation_input_token_cost'],
132
+ cache_read_input_token_cost: model_data['cache_read_input_token_cost'],
133
+ input_cost_per_token_above_200k: model_data['input_cost_per_token_above_200k_tokens'],
134
+ output_cost_per_token_above_200k: model_data['output_cost_per_token_above_200k_tokens'],
135
+ cache_creation_cost_above_200k: model_data['cache_creation_input_token_cost_above_200k_tokens'],
136
+ cache_read_cost_above_200k: model_data['cache_read_input_token_cost_above_200k_tokens'],
137
+ max_input_tokens: model_data['max_input_tokens']
138
+ }.compact
139
+ end
140
+
141
+ pricing
142
+ end
143
+
144
+ def logger
145
+ defined?(Rails) ? Rails.logger : Logger.new($stdout)
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeUsage
4
+ # Shared utility for detecting and resolving project names
5
+ module ProjectNameResolver
6
+ module_function
7
+
8
+ # Auto-detect project name from Rails.root
9
+ # Converts: /Users/name/projects/myapp → -Users-name-projects-myapp
10
+ def detect_from_rails_root
11
+ return nil unless defined?(Rails) && Rails.root
12
+
13
+ # Claude Code uses the absolute path with dashes as the folder name
14
+ # Remove leading slash, then replace remaining slashes with dashes, then prepend dash
15
+ Rails.root.to_s.sub(%r{^/}, '').gsub('/', '-').prepend('-')
16
+ end
17
+
18
+ # Get the project name with fallback
19
+ def resolve(configured_name = nil, fallback: 'your-project-name')
20
+ configured_name || detect_from_rails_root || fallback
21
+ end
22
+ end
23
+ end