lux-hammer 0.3.16 → 0.3.17

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1e64c1f3c63eddb83350353d2a5f5977e5afe544a68d9ed43eea129debc017e7
4
- data.tar.gz: 8e5cfe7fa2ff99d970f172b77e5d23fb7ea27722f5103cfd2edf2c4aba4d5579
3
+ metadata.gz: b3fcc893fd7391f6b7cdd1694fd243729b3319344727a88bcf8599cdd1d23e6d
4
+ data.tar.gz: f722dd982a954141f551832b8d742ba317d5aff3430611929dd2a261df550545
5
5
  SHA512:
6
- metadata.gz: 452eee357de3639419009dae5b0160ab337e0dbea42aee6c44e084284e5e5557d2c2df8c803ed2bd8564e4808f49e1422c1bb9d5ea9a6bbbab57f1f217f2084a
7
- data.tar.gz: 6572ee28bdb6e8f26328a315d29bdde5bb0a63190b13fa7255008b3d44db23c3337616fb485b78e3eeab73822d30e1c1263457ee473c00a94dae073e65ded3c6
6
+ metadata.gz: 43d2baaf2c2451279d8d9ffb1d78209cff145471781858401efa313c274914795ec03aa078be8b1b083d7ed89bf6fe327b8d592c6e4a25ed6744e1d15b382fe7
7
+ data.tar.gz: 37e28dc827e1492f3ac5efd346edd2bdafac3bd6d53a6e267f8f4a49937813d46ca514ecf9a2bb0f39ec6b52e8b098ee04f4dc58633948c9bf5e042e3512707c
data/.version CHANGED
@@ -1 +1 @@
1
- 0.3.16
1
+ 0.3.17
@@ -0,0 +1,507 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'open3'
6
+ require 'shellwords'
7
+ require 'time'
8
+
9
+ module LlmUsage
10
+ UsageRow = Struct.new(
11
+ :name,
12
+ :session_pct,
13
+ :session_reset,
14
+ :week_pct,
15
+ :week_reset,
16
+ :month_pct,
17
+ :month_reset,
18
+ keyword_init: true
19
+ )
20
+
21
+ CACHE_TTL ||= 180
22
+ CACHE_DIR ||= File.expand_path('~/.cache/llm')
23
+ GROK_LOG_PATH ||= File.expand_path('~/.grok/logs/unified.jsonl')
24
+ CODEX_INIT_RPC ||= '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"llm-usage","version":"1.0"}}}'
25
+ CODEX_LIMITS_RPC ||= '{"jsonrpc":"2.0","id":2,"method":"account/rateLimits/read","params":{}}'
26
+ module_function
27
+
28
+ def collect_rows(period: nil, providers: nil, now: Time.now, cache: true)
29
+ wanted = normalize_providers(providers)
30
+ rows = []
31
+ notes = []
32
+ label = period_label(period)
33
+
34
+ if wanted.include?(:claude)
35
+ data, note = fetch_cached(:claude, cache) { fetch_claude_usage }
36
+ notes << note if note
37
+ notes << 'claude: month billing requires OAuth API (not available locally)' if label == 'month'
38
+ rows.concat(parse_claude(data, now: now)) if data
39
+ end
40
+
41
+ if wanted.include?(:codex)
42
+ data, note = fetch_cached(:codex, cache) { fetch_codex_usage }
43
+ notes << note if note
44
+ row = parse_codex(data, now: now)
45
+ rows << row if row
46
+ end
47
+
48
+ if wanted.include?(:grok)
49
+ data, note = fetch_cached(:grok, cache) { fetch_grok_usage }
50
+ notes << note if note
51
+ row = parse_grok(data, now: now)
52
+ rows << row if row
53
+ end
54
+
55
+ [rows, notes, label]
56
+ end
57
+
58
+ def render_table(rows, period: nil)
59
+ return '' if rows.empty?
60
+
61
+ headers = table_headers(period)
62
+ widths = column_widths(rows, headers)
63
+ lines = []
64
+ lines << format_row(headers, widths, align: headers.map { :left })
65
+ rows.each do |row|
66
+ lines << format_row(row_cells(row, period), widths)
67
+ end
68
+ lines.join("\n")
69
+ end
70
+
71
+ def rows_to_json(rows, period: nil, notes: [])
72
+ {
73
+ period: period_label(period),
74
+ rows: rows.map { |row| row_to_hash(row, period: period) },
75
+ notes: notes
76
+ }
77
+ end
78
+
79
+ def format_pct(value)
80
+ return '-' if value.nil?
81
+ "#{value.round}%"
82
+ end
83
+
84
+ def format_reset_short(at, now: Time.now)
85
+ target = parse_time(at)
86
+ return '-' unless target
87
+
88
+ remaining = (target - now).to_i
89
+ return '-' if remaining <= 0
90
+
91
+ days, rem = remaining.divmod(86_400)
92
+ if days >= 1
93
+ hours = rem / 3600
94
+ parts = ["#{days}d"]
95
+ parts << "#{hours}h" if hours.positive?
96
+ return parts.join(' ')
97
+ end
98
+
99
+ hours, hour_rem = remaining.divmod(3600)
100
+ if hours >= 1
101
+ mins = hour_rem / 60
102
+ parts = ["#{hours}h"]
103
+ parts << "#{mins}m" if mins.positive?
104
+ return parts.join(' ')
105
+ end
106
+
107
+ mins = remaining / 60
108
+ return '-' if mins <= 0
109
+
110
+ "#{mins}m"
111
+ end
112
+
113
+ def parse_claude(data, now: Time.now)
114
+ return [] unless data.is_a?(Hash)
115
+
116
+ five = data['five_hour']
117
+ session_pct = five&.dig('utilization')
118
+ session_reset = five&.dig('resets_at')
119
+ month = claude_month_fields(data['extra_usage'])
120
+
121
+ opus = data['seven_day_opus']
122
+ sonnet = data['seven_day_sonnet']
123
+ rows = []
124
+
125
+ if opus.is_a?(Hash) || sonnet.is_a?(Hash)
126
+ if opus.is_a?(Hash)
127
+ rows << UsageRow.new(
128
+ name: 'Opus',
129
+ session_pct: format_pct(session_pct),
130
+ session_reset: format_reset_short(session_reset, now: now),
131
+ week_pct: format_pct(opus['utilization']),
132
+ week_reset: format_reset_short(opus['resets_at'], now: now),
133
+ month_pct: month[:pct],
134
+ month_reset: month[:reset]
135
+ )
136
+ end
137
+ if sonnet.is_a?(Hash)
138
+ rows << UsageRow.new(
139
+ name: 'Sonnet',
140
+ session_pct: '-',
141
+ session_reset: '-',
142
+ week_pct: format_pct(sonnet['utilization']),
143
+ week_reset: format_reset_short(sonnet['resets_at'], now: now),
144
+ month_pct: '-',
145
+ month_reset: '-'
146
+ )
147
+ end
148
+ return rows
149
+ end
150
+
151
+ week = data['seven_day']
152
+ return [] unless five.is_a?(Hash) || week.is_a?(Hash)
153
+
154
+ [
155
+ UsageRow.new(
156
+ name: 'Claude',
157
+ session_pct: format_pct(session_pct),
158
+ session_reset: format_reset_short(session_reset, now: now),
159
+ week_pct: format_pct(week&.dig('utilization')),
160
+ week_reset: format_reset_short(week&.dig('resets_at'), now: now),
161
+ month_pct: month[:pct],
162
+ month_reset: month[:reset]
163
+ )
164
+ ]
165
+ end
166
+
167
+ def parse_codex(data, now: Time.now)
168
+ return nil unless data.is_a?(Hash)
169
+
170
+ limits = data['rateLimits']
171
+ limits ||= codex_whitelist_to_rate_limits(data) if data['rate_limit']
172
+ return nil unless limits
173
+
174
+ primary = limits['primary'] || limits['primary_window']
175
+ secondary = limits['secondary'] || limits['secondary_window']
176
+ session = nil
177
+ week = nil
178
+ [primary, secondary].compact.each do |window|
179
+ mins = window['windowDurationMins'] || (window['limit_window_seconds'].to_i / 60)
180
+ if mins <= 360
181
+ session = window
182
+ elsif mins >= 1440
183
+ week = window
184
+ end
185
+ end
186
+ week ||= primary if primary && !session
187
+ session ||= primary if primary && !week
188
+
189
+ UsageRow.new(
190
+ name: 'Codex',
191
+ session_pct: format_pct(window_pct(session)),
192
+ session_reset: format_reset_short(window_reset(session), now: now),
193
+ week_pct: format_pct(window_pct(week)),
194
+ week_reset: format_reset_short(window_reset(week), now: now)
195
+ )
196
+ end
197
+
198
+ def parse_grok(data, now: Time.now)
199
+ return nil unless data.is_a?(Hash)
200
+
201
+ config = data['config'] || data
202
+ week_pct = config['creditUsagePercent']
203
+ week_reset = config['billingPeriodEnd'] || config.dig('currentPeriod', 'end')
204
+ build = Array(config['productUsage']).find { |p| p['product'] == 'GrokBuild' }
205
+
206
+ UsageRow.new(
207
+ name: 'Grok',
208
+ session_pct: '-',
209
+ session_reset: '-',
210
+ week_pct: format_pct(build ? build['usagePercent'] : week_pct),
211
+ week_reset: format_reset_short(week_reset, now: now)
212
+ )
213
+ end
214
+
215
+ def fetch_claude_usage
216
+ [nil, 'claude: no local snapshot']
217
+ end
218
+
219
+ def fetch_codex_usage
220
+ data, note = codex_app_server_rate_limits
221
+ return [data, note] if data
222
+
223
+ data, fallback_note = codex_session_rate_limits
224
+ return [data, fallback_note] if data
225
+
226
+ [nil, note || fallback_note || 'codex: no local rate limits']
227
+ end
228
+
229
+ def fetch_grok_usage
230
+ grok_log_billing_config
231
+ end
232
+
233
+ def codex_app_server_rate_limits
234
+ bin = find_executable('codex')
235
+ return [nil, 'codex: not in PATH'] unless bin
236
+
237
+ pipe = Shellwords.shelljoin([bin, 'app-server'])
238
+ cmd = "( printf '%s\\n' #{Shellwords.escape(CODEX_INIT_RPC)}; sleep 0.2; " \
239
+ "printf '%s\\n' #{Shellwords.escape(CODEX_LIMITS_RPC)}; sleep 2 ) | #{pipe}"
240
+ out, status = Open3.capture2('sh', '-c', cmd)
241
+ return [nil, 'codex: app-server rate limits unavailable'] unless status.success?
242
+
243
+ limits = nil
244
+ out.each_line do |line|
245
+ msg = JSON.parse(line)
246
+ next unless msg['id'] == 2 && msg['result']
247
+
248
+ limits = msg.dig('result', 'rateLimits')
249
+ rescue JSON::ParserError
250
+ next
251
+ end
252
+
253
+ return [nil, 'codex: app-server rate limits unavailable'] unless limits.is_a?(Hash)
254
+
255
+ [{ 'rateLimits' => limits }, nil]
256
+ rescue Errno::ENOENT
257
+ [nil, 'codex: not in PATH']
258
+ rescue StandardError => e
259
+ [nil, "codex: #{e.message}"]
260
+ end
261
+
262
+ def codex_session_rate_limits
263
+ pattern = File.expand_path('~/.codex/sessions/**/rollout-*.jsonl')
264
+ files = Dir.glob(pattern)
265
+ return [nil, nil] if files.empty?
266
+
267
+ newest = files.max_by { |f| File.mtime(f) }
268
+ limits = nil
269
+ File.foreach(newest) do |line|
270
+ next if line.strip.empty?
271
+
272
+ data = JSON.parse(line)
273
+ next unless data.dig('payload', 'type') == 'token_count'
274
+
275
+ rl = data.dig('payload', 'rate_limits')
276
+ limits = rl if rl.is_a?(Hash)
277
+ rescue JSON::ParserError
278
+ next
279
+ end
280
+ return [nil, nil] unless limits
281
+
282
+ mapped = {
283
+ 'rateLimits' => {
284
+ 'primary' => map_codex_session_window(limits['primary']),
285
+ 'secondary' => map_codex_session_window(limits['secondary'])
286
+ }
287
+ }
288
+ date = File.mtime(newest).strftime('%b %-d')
289
+ [mapped, "codex: from session log (#{date}, may be stale)"]
290
+ end
291
+
292
+ def grok_log_billing_config
293
+ return [nil, 'grok: no billing log (run grok once)'] unless File.file?(GROK_LOG_PATH)
294
+
295
+ entry = reverse_find_jsonl(GROK_LOG_PATH) { |obj| obj['msg'] == 'billing: fetched credits config' }
296
+ return [nil, 'grok: no billing log (run grok once)'] unless entry
297
+
298
+ config = entry.dig('ctx', 'config')
299
+ return [nil, 'grok: billing log missing config'] unless config.is_a?(Hash)
300
+
301
+ note = grok_staleness_note(entry['ts'], now: Time.now)
302
+ [{ 'config' => config }, note]
303
+ end
304
+
305
+ def reverse_find_jsonl(path)
306
+ File.readlines(path).reverse_each do |line|
307
+ next if line.strip.empty?
308
+
309
+ obj = JSON.parse(line)
310
+ return obj if yield(obj)
311
+ rescue JSON::ParserError
312
+ next
313
+ end
314
+ nil
315
+ end
316
+
317
+ def grok_staleness_note(ts, now: Time.now)
318
+ return nil unless ts
319
+
320
+ logged_at = Time.parse(ts)
321
+ age = now - logged_at
322
+ return nil if age < 3600
323
+
324
+ format_age_note('grok: billing log from', age)
325
+ rescue ArgumentError
326
+ nil
327
+ end
328
+
329
+ def format_age_note(prefix, seconds)
330
+ if seconds >= 86_400
331
+ days = (seconds / 86_400).to_i
332
+ "#{prefix} #{days}d ago"
333
+ elsif seconds >= 3600
334
+ hours = (seconds / 3600).to_i
335
+ "#{prefix} #{hours}h ago"
336
+ else
337
+ mins = (seconds / 60).to_i
338
+ "#{prefix} #{mins}m ago"
339
+ end
340
+ end
341
+
342
+ def find_executable(cmd)
343
+ path, status = Open3.capture2('sh', '-c', "command -v #{cmd}")
344
+ path = path.strip
345
+ return nil unless status.success? && !path.empty? && File.executable?(path)
346
+
347
+ path
348
+ end
349
+
350
+ def fetch_cached(provider, use_cache)
351
+ return yield unless use_cache
352
+
353
+ path = cache_path(provider)
354
+ if File.file?(path)
355
+ age = Time.now - File.mtime(path)
356
+ if age < CACHE_TTL
357
+ payload = read_json(path)
358
+ return [payload['data'], payload['note']] if payload && payload.key?('data')
359
+ end
360
+ end
361
+
362
+ data, note = yield
363
+ if data
364
+ FileUtils.mkdir_p(CACHE_DIR)
365
+ File.write(path, JSON.generate('fetched_at' => Time.now.iso8601, 'data' => data, 'note' => note))
366
+ end
367
+ [data, note]
368
+ end
369
+
370
+ def cache_path(provider)
371
+ File.join(CACHE_DIR, "usage-#{provider}.json")
372
+ end
373
+
374
+ def normalize_providers(providers)
375
+ list = Array(providers).map(&:to_s).map(&:downcase).reject(&:empty?)
376
+ return %i[claude codex grok] if list.empty?
377
+
378
+ valid = %w[claude codex grok]
379
+ bad = list - valid
380
+ raise Hammer::Error, "unknown provider(s): #{bad.join(', ')} (valid: #{valid.join(', ')})" unless bad.empty?
381
+
382
+ list.map(&:to_sym)
383
+ end
384
+
385
+ def period_label(period)
386
+ case period.to_s.downcase
387
+ when '', 'default', 'session', 'week' then 'default'
388
+ when 'month' then 'month'
389
+ else
390
+ raise Hammer::Error, "unknown period: #{period} (valid: week, month)"
391
+ end
392
+ end
393
+
394
+ def table_headers(period)
395
+ if period_label(period) == 'month'
396
+ %w[Name month\ util month\ reset]
397
+ else
398
+ %w[Name session\ util session\ reset week\ util week\ reset]
399
+ end
400
+ end
401
+
402
+ def row_cells(row, period)
403
+ if period_label(period) == 'month'
404
+ [row.name, row.month_pct || '-', row.month_reset || '-']
405
+ else
406
+ [row.name, row.session_pct, row.session_reset, row.week_pct, row.week_reset]
407
+ end
408
+ end
409
+
410
+ def row_to_hash(row, period: nil)
411
+ if period_label(period) == 'month'
412
+ { name: row.name, month_util: row.month_pct, month_reset: row.month_reset }
413
+ else
414
+ {
415
+ name: row.name,
416
+ session_util: row.session_pct,
417
+ session_reset: row.session_reset,
418
+ week_util: row.week_pct,
419
+ week_reset: row.week_reset
420
+ }
421
+ end
422
+ end
423
+
424
+ def column_widths(rows, headers)
425
+ cells = [headers] + rows.map { |row| row_cells(row, nil) }
426
+ headers.each_index.map do |i|
427
+ cells.map { |line| line[i].to_s.length }.max
428
+ end
429
+ end
430
+
431
+ def format_row(cells, widths, align: nil)
432
+ align ||= [:left] + [:right] * (cells.length - 1)
433
+ cells.each_with_index.map do |cell, i|
434
+ text = cell.to_s
435
+ if align[i] == :right
436
+ text.rjust(widths[i])
437
+ else
438
+ text.ljust(widths[i])
439
+ end
440
+ end.join(' ')
441
+ end
442
+
443
+ def parse_time(at)
444
+ case at
445
+ when nil then nil
446
+ when Numeric then Time.at(at.to_i)
447
+ when String
448
+ return Time.at(at.to_i) if at.match?(/\A\d+\z/)
449
+ Time.parse(at)
450
+ else
451
+ nil
452
+ end
453
+ rescue ArgumentError
454
+ nil
455
+ end
456
+
457
+ def window_pct(window)
458
+ return nil unless window.is_a?(Hash)
459
+ window['usedPercent'] || window['used_percent']
460
+ end
461
+
462
+ def window_reset(window)
463
+ return nil unless window.is_a?(Hash)
464
+ window['resetsAt'] || window['reset_at']
465
+ end
466
+
467
+ def map_codex_window(window)
468
+ return nil unless window.is_a?(Hash)
469
+ {
470
+ 'used_percent' => window['used_percent'],
471
+ 'limit_window_seconds' => window['limit_window_seconds'],
472
+ 'reset_at' => window['reset_at']
473
+ }
474
+ end
475
+
476
+ def map_codex_session_window(window)
477
+ return nil unless window.is_a?(Hash)
478
+ {
479
+ 'used_percent' => window['used_percent'],
480
+ 'windowDurationMins' => window['window_minutes'],
481
+ 'resetsAt' => window['resets_at']
482
+ }
483
+ end
484
+
485
+ def claude_month_fields(extra)
486
+ return { pct: '-', reset: '-' } unless extra.is_a?(Hash) && extra['is_enabled']
487
+
488
+ {
489
+ pct: format_pct(extra['utilization']),
490
+ reset: '-'
491
+ }
492
+ end
493
+
494
+ def codex_whitelist_to_rate_limits(data)
495
+ {
496
+ 'primary' => map_codex_window(data.dig('rate_limit', 'primary_window')),
497
+ 'secondary' => map_codex_window(data.dig('rate_limit', 'secondary_window'))
498
+ }
499
+ end
500
+
501
+ def read_json(path)
502
+ return nil unless path && File.file?(path)
503
+ JSON.parse(File.read(path))
504
+ rescue JSON::ParserError
505
+ nil
506
+ end
507
+ end
data/recipes/llm.rb CHANGED
@@ -8,6 +8,9 @@ desc <<~TXT
8
8
  Namespaces:
9
9
  memory persistent memory store (backs the Claude Code memory plugin)
10
10
  prompt token-prefix prompt expander (UserPromptSubmit hook + CLI)
11
+
12
+ Commands:
13
+ usage Claude, Codex, and Grok subscription usage (session + week table)
11
14
  TXT
12
15
 
13
16
  require 'fileutils'
@@ -15,11 +18,50 @@ require 'json'
15
18
  require 'pathname'
16
19
  require 'set'
17
20
 
21
+ _llm_root = File.dirname(Hammer::Recipe.path('llm') || File.join(Hammer::Recipe::GEM_DIR, 'llm.rb'))
22
+ require File.join(_llm_root, 'lib/llm/usage')
23
+
18
24
  STORE ||= ENV['CLAUDE_MEMORY_STORE'] || File.expand_path('~/dev/skills/memory')
19
25
  VALID_TYPES ||= %w[user feedback project reference].freeze
20
26
 
21
27
  FileUtils.mkdir_p(STORE)
22
28
 
29
+ task :usage do
30
+ desc <<~D
31
+ Show subscription usage for Claude, Codex, and Grok in one table.
32
+
33
+ Default view lists session and weekly windows side by side.
34
+ Reads Codex via `codex app-server`, Grok from `~/.grok/logs/unified.jsonl`.
35
+ Claude has no local snapshot. Pass `month` for Claude extra-usage when enabled.
36
+ D
37
+ example 'llm usage'
38
+ example 'llm usage month'
39
+ example 'llm usage --json'
40
+ example 'llm usage --provider grok'
41
+
42
+ opt :json, type: :boolean, default: false, desc: 'emit JSON'
43
+ opt :provider, desc: 'single provider (claude, codex, grok)'
44
+
45
+ proc do |opts|
46
+ period = opts[:args].first
47
+ providers = opts[:provider] ? [opts[:provider]] : nil
48
+ rows, notes, = LlmUsage.collect_rows(period: period, providers: providers)
49
+
50
+ if rows.empty?
51
+ notes.each { |note| say note, :yellow }
52
+ error 'no usage data (sign in to Claude, Codex, or Grok first)' if notes.empty?
53
+ error 'no usage data available'
54
+ end
55
+
56
+ if opts[:json]
57
+ puts JSON.generate(LlmUsage.rows_to_json(rows, period: period, notes: notes))
58
+ else
59
+ say LlmUsage.render_table(rows, period: period)
60
+ notes.each { |note| say note, :gray }
61
+ end
62
+ end
63
+ end
64
+
23
65
  namespace :memory do
24
66
  # Helpers are defined inside the namespace block (class_eval'd on the
25
67
  # namespace's anonymous Hammer subclass) so the task procs reach them.
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lux-hammer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.16
4
+ version: 0.3.17
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dino Reic
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-11 00:00:00.000000000 Z
11
+ date: 2026-07-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: minitest
@@ -63,6 +63,7 @@ files:
63
63
  - "./recipes/lib/deploy/manifest.rb"
64
64
  - "./recipes/lib/deploy/ssh.rb"
65
65
  - "./recipes/lib/deploy/template.rb"
66
+ - "./recipes/lib/llm/usage.rb"
66
67
  - "./recipes/llm.rb"
67
68
  - "./recipes/srt.rb"
68
69
  - bin/hammer