lux-hammer 0.3.16 → 0.3.20

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.
data/recipes/llm.rb CHANGED
@@ -8,6 +8,10 @@ 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 subscription limits and per-model token usage
14
+ wrap run a command with your last two prompts pinned to the screen
11
15
  TXT
12
16
 
13
17
  require 'fileutils'
@@ -15,11 +19,127 @@ require 'json'
15
19
  require 'pathname'
16
20
  require 'set'
17
21
 
18
- STORE ||= ENV['CLAUDE_MEMORY_STORE'] || File.expand_path('~/dev/skills/memory')
22
+ # Resolve next to this recipe file (realpath so PATH symlinks to a dev
23
+ # checkout still load matching lib/, not an older installed gem copy).
24
+ _llm_root = File.dirname(File.realpath(__FILE__))
25
+ require File.join(_llm_root, 'lib/llm/usage')
26
+
27
+ STORE ||= ENV['CLAUDE_MEMORY_STORE'] || File.expand_path('~/dev/ai/memory')
19
28
  VALID_TYPES ||= %w[user feedback project reference].freeze
20
29
 
21
30
  FileUtils.mkdir_p(STORE)
22
31
 
32
+ task :usage do
33
+ desc <<~D
34
+ Show subscription limits and per-model token usage for Claude, Codex, and Grok.
35
+
36
+ Default view lists session and weekly windows side by side.
37
+ Local Claude, Codex, and Grok sessions provide calendar day, week, and month token totals.
38
+ Reads Codex via `codex app-server`; Grok billing + tokens from `~/.grok/logs/unified.jsonl`
39
+ (inference_done events). Claude 5h/week windows come from the statusline snapshot at
40
+ `~/.cache/llm/claude-limits.json`, falling back to the OAuth usage API. `month` shows
41
+ Claude extra-usage credits (API only, and only when you've enabled them).
42
+ D
43
+ example 'llm usage'
44
+ example 'llm usage month'
45
+ example 'llm usage --json'
46
+ example 'llm usage --provider grok'
47
+
48
+ # :period is declared first on purpose — the parser fills un-set scalar opts
49
+ # from positionals in declaration order, so `llm usage month` must reach
50
+ # :period before :provider can claim it.
51
+ opt :period, desc: 'window to show (week, month)', placeholder: 'week|month'
52
+ opt :json, type: :boolean, default: false, desc: 'emit JSON'
53
+ opt :provider, desc: 'single provider (claude, codex, grok)'
54
+
55
+ proc do |opts|
56
+ period = opts[:period]
57
+ providers = opts[:provider] ? [opts[:provider]] : nil
58
+ rows, notes, = LlmUsage.collect_rows(period: period, providers: providers)
59
+ token_rows = LlmUsage.collect_token_rows(providers: providers)
60
+
61
+ if rows.empty? && token_rows.empty?
62
+ notes.each { |note| say note, :yellow }
63
+ error 'no usage data (sign in to Claude, Codex, or Grok first)' if notes.empty?
64
+ error 'no usage data available'
65
+ end
66
+
67
+ if opts[:json]
68
+ puts JSON.generate(
69
+ LlmUsage.rows_to_json(rows, period: period, notes: notes, token_rows: token_rows)
70
+ )
71
+ else
72
+ tables = []
73
+ tables << LlmUsage.render_table(rows, period: period) unless rows.empty?
74
+ tables << LlmUsage.render_token_table(token_rows) unless token_rows.empty?
75
+ say tables.join("\n\n")
76
+ notes.each { |note| say note, :gray }
77
+ end
78
+ end
79
+ end
80
+
81
+ task :wrap do
82
+ desc <<~D
83
+ Run a command with the last few prompts you typed pinned to the bottom of the screen.
84
+
85
+ The command runs in a PTY that is shorter than the real terminal by the height of
86
+ the bar, so those rows belong to the bar and nothing the program draws can cover
87
+ them - no hooks, no tmux, no terminal-specific plumbing. Works with claude, codex,
88
+ a REPL, a plain shell.
89
+
90
+ The bar is a "prompt history" rule with the prompts below it, oldest first, so the
91
+ newest one sits on the very last line. It is --lines + 1 rows tall.
92
+
93
+ Each prompt gets one row: a multi-line prompt is flattened onto it with line breaks
94
+ shown as a literal \\n, and the whole thing is clipped to the terminal width.
95
+
96
+ A prompt is whatever you type between Enters. Shift/Option+Enter and pasted newlines
97
+ keep appending to the current one. This is keystroke sniffing, so text the program
98
+ inserts for you (history recall, autocomplete, menu picks) will not show up, and
99
+ nothing is captured while the program has echo off in canonical mode (sudo, ssh).
100
+
101
+ Keys are decoded from plain bytes, the kitty keyboard protocol and xterm
102
+ modifyOtherKeys, since a program can ask the terminal for any of the three (Claude
103
+ Code turns on the last two). Mouse reports, focus events, arrows and Ctrl-<key> are
104
+ not text and are dropped - including Ctrl-V, which pastes an image in Claude Code.
105
+
106
+ LLM_WRAP_DEBUG=<file> logs what arrived and how it was read, for when a terminal
107
+ reports keys in some way this does not know about. It records every keystroke.
108
+
109
+ Put `--` before the wrapped command when it has flags of its own, otherwise they are
110
+ parsed as flags to `llm wrap`.
111
+ D
112
+ example 'llm wrap claude'
113
+ example 'llm wrap -- claude --resume'
114
+ example 'llm wrap --lines 5 -- bash -l'
115
+
116
+ # `positional: false` is load-bearing: the parser fills un-set scalar opts
117
+ # from positionals in declaration order, so without it `llm wrap claude`
118
+ # would hand "claude" to --lines and die on the integer cast.
119
+ opt :lines, type: :integer, default: 3, positional: false,
120
+ desc: 'how many recent prompts to pin', placeholder: 'N'
121
+
122
+ proc do |opts|
123
+ cmd = Array(opts[:args])
124
+ error 'usage: llm wrap [--lines N] [--] <command> [args...]' if cmd.empty?
125
+
126
+ # Required lazily - llm.rb also runs as a per-prompt hook (`llm prompt
127
+ # hook`), which should not pay to load pty/io-console. `_llm_root` is a
128
+ # local from the file scope; the block closes over it (instance_exec
129
+ # rebinds self, not locals).
130
+ require File.join(_llm_root, 'lib/llm/wrap')
131
+
132
+ # The wrapper renames itself after the program it runs, so that a pane
133
+ # watcher can still see which agent is in there - which leaves nothing
134
+ # behind that says how the pane was started. This is that: the invocation
135
+ # rebuilt from what the parser gave us, since by then the real command line
136
+ # is a resolved interpreter chain nobody typed. `--` keeps a wrapped
137
+ # command's own flags out of our parser when it is read back.
138
+ origin = ['llm', 'wrap', '--lines', opts[:lines].to_s, '--', *cmd]
139
+ exit LlmWrap.run(cmd, keep: opts[:lines], origin:)
140
+ end
141
+ end
142
+
23
143
  namespace :memory do
24
144
  # Helpers are defined inside the namespace block (class_eval'd on the
25
145
  # namespace's anonymous Hammer subclass) so the task procs reach them.
@@ -97,15 +217,21 @@ namespace :memory do
97
217
  Memory types: user, feedback, project, reference.
98
218
  DESC
99
219
  example %(echo "deep Go expertise, new to React" | llm memory write user-role --type=user --description="user profile")
220
+ # :name first — the parser fills scalar opts from positionals in
221
+ # declaration order, so without it `llm memory write foo --type=user`
222
+ # lands "foo" in :description instead of the memory name.
223
+ opt :name, desc: 'memory name (slug)'
100
224
  opt :type, desc: 'memory type (user|feedback|project|reference)', req: true
101
225
  opt :description, desc: 'one-line summary stored in frontmatter'
102
226
 
103
227
  proc do |opts|
104
- name = opts[:args].first
228
+ name = opts[:name]
105
229
  error 'usage: llm memory write <name> --type=<type> [--description="..."] < body' unless name
106
230
  error "unknown type: #{opts[:type]} (valid: #{VALID_TYPES.join(', ')})" unless VALID_TYPES.include?(opts[:type])
107
231
 
108
- body = $stdin.read
232
+ # opts[:stdin], not $stdin.read — hammer drains piped stdin into opts
233
+ # before the handler runs, so a direct read comes back empty.
234
+ body = opts[:stdin].to_s
109
235
  error 'body is empty (pipe content on stdin)' if body.strip.empty?
110
236
 
111
237
  path = memory_path(name)
@@ -423,7 +549,7 @@ namespace :prompt do
423
549
  proc do |opts|
424
550
  error '--claude or --codex required' unless opts[:claude] || opts[:codex]
425
551
 
426
- raw = $stdin.read
552
+ raw = opts[:stdin].to_s
427
553
  prompt = begin
428
554
  JSON.parse(raw).fetch('prompt', raw)
429
555
  rescue JSON::ParserError
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.20
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-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: minitest
@@ -46,6 +46,7 @@ files:
46
46
  - "./lib/hammer/cron_server.rb"
47
47
  - "./lib/hammer/cron_web.rb"
48
48
  - "./lib/hammer/dotenv.rb"
49
+ - "./lib/hammer/input.rb"
49
50
  - "./lib/hammer/loader.rb"
50
51
  - "./lib/hammer/option.rb"
51
52
  - "./lib/hammer/parser.rb"
@@ -63,6 +64,8 @@ files:
63
64
  - "./recipes/lib/deploy/manifest.rb"
64
65
  - "./recipes/lib/deploy/ssh.rb"
65
66
  - "./recipes/lib/deploy/template.rb"
67
+ - "./recipes/lib/llm/usage.rb"
68
+ - "./recipes/lib/llm/wrap.rb"
66
69
  - "./recipes/llm.rb"
67
70
  - "./recipes/srt.rb"
68
71
  - bin/hammer