lux-hammer 0.3.27 → 0.3.29
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/.version +1 -1
- data/AGENTS.md +11 -7
- data/README.md +1 -1
- data/lib/lux-hammer.rb +6 -11
- data/recipes/lib/llm/browser.rb +65 -0
- data/recipes/lib/llm/launch.rb +5 -4
- data/recipes/lib/llm/usage.rb +113 -14
- data/recipes/llm.rb +115 -15
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6dab0deb8eba80ec1aac72b16620370af2d80d2528641e9117be71df84e8d416
|
|
4
|
+
data.tar.gz: 6460bbda170ba18cedf234846e4898f1c6efc48732c81ed122fc6eaa11d237b8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c24c74422db1ac9a72b5a03ae4f7d4e56e486c7622be40f5e136c16b9a15b2a04feeb7d2f61cdfdaaeafbc5c3913e71c2f8faf567cac8ea236eaeeadcb7fcb4f
|
|
7
|
+
data.tar.gz: 823288d67fd112a118c09b3b1dd3599a3b080c5c74fd6e5a35492be182a72fa3fbe82359498e1facb0396b61773b1a51e61dba786284d2bde19426a8e3eddc81
|
data/.version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.3.
|
|
1
|
+
0.3.29
|
data/AGENTS.md
CHANGED
|
@@ -245,12 +245,15 @@ explicit ADR-level discussion. Keys:
|
|
|
245
245
|
|
|
246
246
|
## Help formatting
|
|
247
247
|
|
|
248
|
-
* Program name in usage lines is
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
248
|
+
* Program name in usage lines is the invocation as typed: a bare name
|
|
249
|
+
from PATH (`lux`), or a relative path that is runnable as printed
|
|
250
|
+
(`bin/foo`). An absolute `$PROGRAM_NAME` (PATH symlink, launchd unit,
|
|
251
|
+
shim) collapses to its basename - help never shows a full path, and
|
|
252
|
+
symlinks are never resolved (that once turned a typed `hammer` into
|
|
253
|
+
`gems/lux-hammer/bin/hammer`). There is no user-facing override; the
|
|
254
|
+
auto-detection is the whole API. `Hammer.cli` warms the cache before
|
|
255
|
+
chdir-ing into the Hammerfile's directory so a relative name stays
|
|
256
|
+
relative to the cwd the user invoked from.
|
|
254
257
|
* `hammer` (no args) routes to the `:default` built-in task, which
|
|
255
258
|
falls through to `print_help(extended: false)` - the brief command
|
|
256
259
|
listing with a top gray `lux-hammer VERSION - <homepage>` banner.
|
|
@@ -431,7 +434,8 @@ new code in this gem should show.
|
|
|
431
434
|
|
|
432
435
|
## Testing
|
|
433
436
|
|
|
434
|
-
* Run: `
|
|
437
|
+
* Run: `bin/hammer test` (the `:test` task in this repo's Hammerfile -
|
|
438
|
+
there is no Rakefile)
|
|
435
439
|
* Single file: `bundle exec ruby -Ilib -Itest test/parser_test.rb`
|
|
436
440
|
* Single test by name: `... test/parser_test.rb -n test_boolean_via_short_alias`
|
|
437
441
|
* Helper `CaptureIO` in `test/test_helper.rb` swaps `$stdout`/`$stderr`
|
data/README.md
CHANGED
|
@@ -1410,7 +1410,7 @@ MyCli.hammer :greet, loud: true
|
|
|
1410
1410
|
git clone https://github.com/dux/hammer
|
|
1411
1411
|
cd lux-hammer
|
|
1412
1412
|
bundle install
|
|
1413
|
-
|
|
1413
|
+
bin/hammer test
|
|
1414
1414
|
```
|
|
1415
1415
|
|
|
1416
1416
|
Tests live in `test/` and use minitest. Run a single file with
|
data/lib/lux-hammer.rb
CHANGED
|
@@ -138,19 +138,14 @@ class Hammer
|
|
|
138
138
|
@app_desc = text.to_s.rstrip
|
|
139
139
|
end
|
|
140
140
|
|
|
141
|
-
# Program name shown in help/usage: the invocation
|
|
142
|
-
#
|
|
143
|
-
#
|
|
144
|
-
#
|
|
141
|
+
# Program name shown in help/usage: the invocation as typed - a bare
|
|
142
|
+
# name from PATH (`lux`), or a relative path that is runnable as
|
|
143
|
+
# printed (`bin/foo`). Absolute paths (PATH symlinks, launchd units,
|
|
144
|
+
# shims) collapse to the basename - help never shows a full path.
|
|
145
145
|
def default_program_name
|
|
146
146
|
prog = $PROGRAM_NAME
|
|
147
|
-
return File.basename(prog)
|
|
148
|
-
|
|
149
|
-
# doesn't cause a false miss when comparing prefixes.
|
|
150
|
-
abs = File.realpath(prog) rescue File.expand_path(prog)
|
|
151
|
-
cwd = File.realpath(Dir.pwd) rescue Dir.pwd
|
|
152
|
-
return abs[(cwd.length + 1)..] if abs.start_with?("#{cwd}/")
|
|
153
|
-
File.basename(prog)
|
|
147
|
+
return File.basename(prog) if prog.start_with?('/')
|
|
148
|
+
prog.sub(%r{\A\./}, '')
|
|
154
149
|
end
|
|
155
150
|
|
|
156
151
|
# Define a command. Block runs in a CommandBuilder context and must
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Builds the commands `llm browser:*` hands to Bun. Stagehand is installed
|
|
4
|
+
# globally with `bun add -g`, and Bun resolves imports from NODE_PATH, so the
|
|
5
|
+
# TypeScript under browser/ can live here while the package lives in the
|
|
6
|
+
# global store. Nothing in this module runs Bun or the network - that is what
|
|
7
|
+
# keeps it testable.
|
|
8
|
+
module LlmBrowser
|
|
9
|
+
GLOBAL_MODULES ||= File.join(Dir.home, '.bun/install/global/node_modules')
|
|
10
|
+
SCRIPT_DIR ||= File.join(__dir__, 'browser')
|
|
11
|
+
RUNNER ||= File.join(SCRIPT_DIR, 'run.ts')
|
|
12
|
+
PACKAGES ||= %w[@browserbasehq/stagehand zod].freeze
|
|
13
|
+
CHROME ||= '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
|
|
14
|
+
|
|
15
|
+
# Who answers Stagehand's act / observe / extract questions. claude and codex
|
|
16
|
+
# go through their CLIs (subscription, no API key); ollama is Stagehand's own
|
|
17
|
+
# provider talking to the local daemon.
|
|
18
|
+
LLMS ||= %w[claude codex ollama].freeze
|
|
19
|
+
|
|
20
|
+
# First backend whose name starts with the prefix: `c` is claude, `co` codex,
|
|
21
|
+
# `o` ollama. No prefix means claude.
|
|
22
|
+
def self.llm(prefix)
|
|
23
|
+
return LLMS.first if prefix.nil? || prefix.empty?
|
|
24
|
+
LLMS.find { |name| name.start_with?(prefix) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.env
|
|
28
|
+
{ 'NODE_PATH' => GLOBAL_MODULES }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.setup_argv
|
|
32
|
+
['bun', 'add', '-g', *PACKAGES]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.run_argv(url, instructions = [], llm: 'claude', headed: false, shot: nil)
|
|
36
|
+
argv = ['bun', RUNNER, url, *instructions, '--llm', llm]
|
|
37
|
+
argv << '--headed' if headed
|
|
38
|
+
argv.push('--shot', shot) if shot
|
|
39
|
+
argv
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.script_argv(path, extra = [])
|
|
43
|
+
['bun', RUNNER, '--script', File.expand_path(path), *extra]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.installed?(modules: GLOBAL_MODULES)
|
|
47
|
+
PACKAGES.all? { |pkg| File.directory?(File.join(modules, pkg)) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# What `browser:setup` reports: name => true/false, in the order it prints.
|
|
51
|
+
def self.check(modules: GLOBAL_MODULES, chrome: CHROME)
|
|
52
|
+
{
|
|
53
|
+
'bun' => command?('bun'),
|
|
54
|
+
'stagehand' => installed?(modules: modules),
|
|
55
|
+
'chrome' => File.exist?(chrome),
|
|
56
|
+
'claude' => command?('claude'),
|
|
57
|
+
'codex' => command?('codex'),
|
|
58
|
+
'ollama' => command?('ollama'),
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.command?(name)
|
|
63
|
+
ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).any? { |dir| File.executable?(File.join(dir, name)) }
|
|
64
|
+
end
|
|
65
|
+
end
|
data/recipes/lib/llm/launch.rb
CHANGED
|
@@ -4,7 +4,8 @@ require 'json'
|
|
|
4
4
|
require 'pathname'
|
|
5
5
|
|
|
6
6
|
# Builds the command `llm wrap:run` hands to LlmWrap: picks the agent CLI by
|
|
7
|
-
# prefix and maps the generic -c / -f
|
|
7
|
+
# prefix and maps the generic -c / -f switches to what that CLI takes. The
|
|
8
|
+
# AGENTS.md chain is injected by default; -b (bare) leaves it out.
|
|
8
9
|
module LlmLaunch
|
|
9
10
|
Launch = Struct.new(:argv, :env, :files)
|
|
10
11
|
|
|
@@ -22,7 +23,7 @@ module LlmLaunch
|
|
|
22
23
|
agents: ->(_text, files) { { env: { 'OPENCODE_CONFIG_CONTENT' => { instructions: files }.to_json } } } },
|
|
23
24
|
}.freeze
|
|
24
25
|
|
|
25
|
-
# Read AGENTS.md from the git root down on their own;
|
|
26
|
+
# Read AGENTS.md from the git root down on their own; we only add the
|
|
26
27
|
# files above it for these. Claude reads CLAUDE.md, so it gets the chain.
|
|
27
28
|
NATIVE_AGENTS ||= %w[codex grok opencode].freeze
|
|
28
29
|
|
|
@@ -33,13 +34,13 @@ module LlmLaunch
|
|
|
33
34
|
TOOLS.keys.find { |name| name.start_with?(prefix) }
|
|
34
35
|
end
|
|
35
36
|
|
|
36
|
-
def self.build(tool, extra = [], continue: false, full: false,
|
|
37
|
+
def self.build(tool, extra = [], continue: false, full: false, bare: false, cwd: Dir.pwd, home: Dir.home)
|
|
37
38
|
spec = TOOLS.fetch(tool)
|
|
38
39
|
argv = [tool]
|
|
39
40
|
argv.concat(spec[:continue]) if continue
|
|
40
41
|
argv.concat(spec[:full]) if full
|
|
41
42
|
|
|
42
|
-
files =
|
|
43
|
+
files = bare ? [] : agents_files(tool, cwd: cwd, home: home)
|
|
43
44
|
env = {}
|
|
44
45
|
if files.any?
|
|
45
46
|
added = spec[:agents].call(agents_text(files), files.map(&:to_s))
|
data/recipes/lib/llm/usage.rb
CHANGED
|
@@ -34,6 +34,11 @@ module LlmUsage
|
|
|
34
34
|
GROK_LOG_PATH ||= File.expand_path('~/.grok/logs/unified.jsonl')
|
|
35
35
|
GROK_SESSION_GLOB ||= File.expand_path('~/.grok/sessions/**/signals.json')
|
|
36
36
|
GROK_SUMMARY_GLOB ||= File.expand_path('~/.grok/sessions/**/summary.json')
|
|
37
|
+
GROK_AUTH_PATH ||= File.expand_path('~/.grok/auth.json')
|
|
38
|
+
# Same endpoint grok's /usage pane hits. 1.0.13+ strips creditUsagePercent
|
|
39
|
+
# from the unified log, so the log is only a reset-time fallback.
|
|
40
|
+
GROK_BILLING_URL ||= 'https://cli-chat-proxy.grok.com/v1/billing?format=credits'
|
|
41
|
+
GROK_TOKEN_AUTH ||= 'xai-grok-cli'
|
|
37
42
|
CLAUDE_SESSION_GLOB ||= File.expand_path('~/.claude/projects/**/*.jsonl')
|
|
38
43
|
# Written by the statusline hook (~/.claude/statusline-command.sh) from its
|
|
39
44
|
# `rate_limits` input; mtime is the observation time.
|
|
@@ -59,7 +64,7 @@ module LlmUsage
|
|
|
59
64
|
# and caching would freeze the staleness note. Only the API call caches.
|
|
60
65
|
data, note = fetch_claude_usage(period: label, cache: cache, now: now)
|
|
61
66
|
notes << note unless note.nil? || note.empty?
|
|
62
|
-
rows.concat(parse_claude(data, now: now)) if data
|
|
67
|
+
rows.concat(parse_claude(data, now: now, period: label)) if data
|
|
63
68
|
end
|
|
64
69
|
|
|
65
70
|
if wanted.include?(:codex)
|
|
@@ -289,7 +294,7 @@ module LlmUsage
|
|
|
289
294
|
"#{mins}m"
|
|
290
295
|
end
|
|
291
296
|
|
|
292
|
-
def parse_claude(data, now: Time.now)
|
|
297
|
+
def parse_claude(data, now: Time.now, period: nil)
|
|
293
298
|
return [] unless data.is_a?(Hash)
|
|
294
299
|
|
|
295
300
|
five = data['five_hour']
|
|
@@ -324,13 +329,13 @@ module LlmUsage
|
|
|
324
329
|
month_reset: '-'
|
|
325
330
|
)
|
|
326
331
|
end
|
|
327
|
-
return rows
|
|
332
|
+
return rows + claude_scoped_rows(data['limits'], rows, now: now, period: period)
|
|
328
333
|
end
|
|
329
334
|
|
|
330
335
|
week = data['seven_day']
|
|
331
336
|
return [] unless five.is_a?(Hash) || week.is_a?(Hash)
|
|
332
337
|
|
|
333
|
-
[
|
|
338
|
+
rows = [
|
|
334
339
|
UsageRow.new(
|
|
335
340
|
name: 'Claude',
|
|
336
341
|
session_pct: format_pct(session_pct),
|
|
@@ -341,6 +346,36 @@ module LlmUsage
|
|
|
341
346
|
month_reset: month[:reset]
|
|
342
347
|
)
|
|
343
348
|
]
|
|
349
|
+
rows + claude_scoped_rows(data['limits'], rows, now: now, period: period)
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# Per-model weekly buckets live in `limits` as kind weekly_scoped - Fable has
|
|
353
|
+
# one, and seven_day_opus/_sonnet read null on the accounts that do. Named off
|
|
354
|
+
# the API's own display_name, so a model added later needs nothing here; a
|
|
355
|
+
# scope already carrying its own seven_day_* row is skipped rather than
|
|
356
|
+
# doubled. A weekly bucket has nothing to say in the month view, where these
|
|
357
|
+
# would be a row of dashes.
|
|
358
|
+
def claude_scoped_rows(limits, rows, now: Time.now, period: nil)
|
|
359
|
+
return [] if period_label(period) == 'month'
|
|
360
|
+
|
|
361
|
+
taken = rows.map(&:name)
|
|
362
|
+
|
|
363
|
+
Array(limits).filter_map do |limit|
|
|
364
|
+
next unless limit.is_a?(Hash) && limit['kind'] == 'weekly_scoped'
|
|
365
|
+
|
|
366
|
+
name = limit.dig('scope', 'model', 'display_name')
|
|
367
|
+
next if name.nil? || name.empty? || taken.include?(name)
|
|
368
|
+
|
|
369
|
+
UsageRow.new(
|
|
370
|
+
name: name,
|
|
371
|
+
session_pct: '-',
|
|
372
|
+
session_reset: '-',
|
|
373
|
+
week_pct: format_pct(limit['percent']),
|
|
374
|
+
week_reset: format_reset_short(limit['resets_at'], now: now),
|
|
375
|
+
month_pct: '-',
|
|
376
|
+
month_reset: '-'
|
|
377
|
+
)
|
|
378
|
+
end
|
|
344
379
|
end
|
|
345
380
|
|
|
346
381
|
def parse_codex(data, now: Time.now)
|
|
@@ -378,15 +413,15 @@ module LlmUsage
|
|
|
378
413
|
return nil unless data.is_a?(Hash)
|
|
379
414
|
|
|
380
415
|
config = data['config'] || data
|
|
381
|
-
week_pct = config['creditUsagePercent']
|
|
382
|
-
week_reset = config['billingPeriodEnd'] || config.dig('currentPeriod', 'end')
|
|
383
416
|
build = Array(config['productUsage']).find { |p| p['product'] == 'GrokBuild' }
|
|
417
|
+
week_pct = (build && build['usagePercent']) || config['creditUsagePercent']
|
|
418
|
+
week_reset = config['billingPeriodEnd'] || config.dig('currentPeriod', 'end')
|
|
384
419
|
|
|
385
420
|
UsageRow.new(
|
|
386
421
|
name: 'Grok',
|
|
387
422
|
session_pct: '-',
|
|
388
423
|
session_reset: '-',
|
|
389
|
-
week_pct: format_pct(
|
|
424
|
+
week_pct: format_pct(week_pct),
|
|
390
425
|
week_reset: format_reset_short(week_reset, now: now)
|
|
391
426
|
)
|
|
392
427
|
end
|
|
@@ -398,7 +433,7 @@ module LlmUsage
|
|
|
398
433
|
return fetch_cached(:claude, cache) { fetch_claude_oauth_usage } if period == 'month'
|
|
399
434
|
|
|
400
435
|
data, note = fetch_claude_snapshot(now: now, path: path)
|
|
401
|
-
return [data, note] if data
|
|
436
|
+
return [merge_scoped_limits(data, cache: cache), note] if data
|
|
402
437
|
|
|
403
438
|
api_data, api_note = fetch_cached(:claude, cache) { fetch_claude_oauth_usage }
|
|
404
439
|
return [api_data, api_note] if api_data
|
|
@@ -406,6 +441,18 @@ module LlmUsage
|
|
|
406
441
|
[nil, [note, api_note].compact.join('; ')]
|
|
407
442
|
end
|
|
408
443
|
|
|
444
|
+
# The statusline snapshot carries no per-model windows - Claude Code's
|
|
445
|
+
# `rate_limits` input is five_hour/seven_day and nothing else - so borrow
|
|
446
|
+
# `limits` from the API payload. Cached, so this is one call per CACHE_TTL,
|
|
447
|
+
# and silent when it fails: the snapshot's own numbers are still good.
|
|
448
|
+
def merge_scoped_limits(data, cache: true)
|
|
449
|
+
api, = fetch_cached(:claude, cache) { fetch_claude_oauth_usage }
|
|
450
|
+
limits = api['limits'] if api.is_a?(Hash)
|
|
451
|
+
return data unless limits
|
|
452
|
+
|
|
453
|
+
data.merge('limits' => limits)
|
|
454
|
+
end
|
|
455
|
+
|
|
409
456
|
def fetch_claude_snapshot(now: Time.now, path: CLAUDE_LIMITS_PATH)
|
|
410
457
|
snapshot = read_json(path)
|
|
411
458
|
return [nil, 'claude: no local snapshot (statusline writer not installed)'] unless snapshot.is_a?(Hash)
|
|
@@ -496,8 +543,59 @@ module LlmUsage
|
|
|
496
543
|
[nil, note || fallback_note || 'codex: no local rate limits']
|
|
497
544
|
end
|
|
498
545
|
|
|
499
|
-
def fetch_grok_usage
|
|
500
|
-
|
|
546
|
+
def fetch_grok_usage(now: Time.now, path: GROK_AUTH_PATH)
|
|
547
|
+
data, note = fetch_grok_oauth_usage(now: now, path: path)
|
|
548
|
+
return [data, note] if data
|
|
549
|
+
|
|
550
|
+
log_data, log_note = grok_log_billing_config
|
|
551
|
+
return [log_data, log_note] if log_data
|
|
552
|
+
|
|
553
|
+
[nil, [note, log_note].compact.join('; ')]
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
def fetch_grok_oauth_usage(now: Time.now, path: GROK_AUTH_PATH)
|
|
557
|
+
token = grok_oauth_token(now: now, path: path)
|
|
558
|
+
return [nil, 'grok: no usable auth token (run `grok login`)'] unless token
|
|
559
|
+
|
|
560
|
+
uri = URI(GROK_BILLING_URL)
|
|
561
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 3, read_timeout: 5) do |http|
|
|
562
|
+
http.get(
|
|
563
|
+
uri.request_uri,
|
|
564
|
+
'Authorization' => "Bearer #{token}",
|
|
565
|
+
'Accept' => 'application/json',
|
|
566
|
+
'X-XAI-Token-Auth' => GROK_TOKEN_AUTH
|
|
567
|
+
)
|
|
568
|
+
end
|
|
569
|
+
return [nil, "grok: billing API returned #{response.code}"] unless response.is_a?(Net::HTTPSuccess)
|
|
570
|
+
|
|
571
|
+
data = JSON.parse(response.body)
|
|
572
|
+
return [nil, 'grok: billing API returned no config'] unless data.is_a?(Hash) && data['config'].is_a?(Hash)
|
|
573
|
+
|
|
574
|
+
[data, nil]
|
|
575
|
+
rescue JSON::ParserError
|
|
576
|
+
[nil, 'grok: billing API returned malformed JSON']
|
|
577
|
+
rescue StandardError => e
|
|
578
|
+
[nil, "grok: billing API unreachable (#{e.message})"]
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
# grok login writes one Hash per issuer (`https://auth.x.ai::<uuid>`).
|
|
582
|
+
# An expired token is treated as absent rather than refreshed here.
|
|
583
|
+
def grok_oauth_token(now: Time.now, path: GROK_AUTH_PATH)
|
|
584
|
+
creds = read_json(path)
|
|
585
|
+
return nil unless creds.is_a?(Hash)
|
|
586
|
+
|
|
587
|
+
creds.each_value do |entry|
|
|
588
|
+
next unless entry.is_a?(Hash)
|
|
589
|
+
|
|
590
|
+
token = entry['key']
|
|
591
|
+
next if token.nil? || token.empty?
|
|
592
|
+
|
|
593
|
+
expires_at = parse_time(entry['expires_at'])
|
|
594
|
+
next if expires_at && expires_at <= now
|
|
595
|
+
|
|
596
|
+
return token
|
|
597
|
+
end
|
|
598
|
+
nil
|
|
501
599
|
end
|
|
502
600
|
|
|
503
601
|
def codex_app_server_rate_limits
|
|
@@ -629,11 +727,12 @@ module LlmUsage
|
|
|
629
727
|
end
|
|
630
728
|
end
|
|
631
729
|
|
|
730
|
+
# A failure is cached too, or an unreachable endpoint costs its timeout on
|
|
731
|
+
# every run - and the snapshot path that now consults the API is the one
|
|
732
|
+
# that used to be instant and offline.
|
|
632
733
|
data, note = yield
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
File.write(path, JSON.generate('fetched_at' => Time.now.iso8601, 'data' => data, 'note' => note))
|
|
636
|
-
end
|
|
734
|
+
FileUtils.mkdir_p(CACHE_DIR)
|
|
735
|
+
File.write(path, JSON.generate('fetched_at' => Time.now.iso8601, 'data' => data, 'note' => note))
|
|
637
736
|
[data, note]
|
|
638
737
|
end
|
|
639
738
|
|
data/recipes/llm.rb
CHANGED
|
@@ -6,6 +6,7 @@ desc <<~TXT
|
|
|
6
6
|
llm - personal LLM utility CLI
|
|
7
7
|
|
|
8
8
|
Namespaces:
|
|
9
|
+
browser drive a local Chrome with Stagehand, thinking through claude / codex / ollama
|
|
9
10
|
memory persistent memory store (backs the Claude Code memory plugin)
|
|
10
11
|
plan apply a /plan bundle - sha1 checked, drift aware
|
|
11
12
|
prompt token-prefix prompt expander (UserPromptSubmit hook + CLI)
|
|
@@ -13,7 +14,7 @@ desc <<~TXT
|
|
|
13
14
|
Commands:
|
|
14
15
|
usage subscription limits and per-model token usage
|
|
15
16
|
wrap run a command with your last two prompts pinned to the screen
|
|
16
|
-
wrap:run start claude / codex / grok / opencode in wrap, same -c -f -
|
|
17
|
+
wrap:run start claude / codex / grok / opencode in wrap, same -c -f -b switches for all
|
|
17
18
|
TXT
|
|
18
19
|
|
|
19
20
|
require 'fileutils'
|
|
@@ -27,6 +28,7 @@ _llm_root = File.dirname(File.realpath(__FILE__))
|
|
|
27
28
|
require File.join(_llm_root, 'lib/llm/usage')
|
|
28
29
|
require File.join(_llm_root, 'lib/llm/plan')
|
|
29
30
|
|
|
31
|
+
BROWSER_LIB ||= File.join(_llm_root, 'lib/llm/browser')
|
|
30
32
|
STORE ||= ENV['CLAUDE_MEMORY_STORE'] || File.expand_path('~/dev/ai/memory')
|
|
31
33
|
VALID_TYPES ||= %w[user feedback project reference].freeze
|
|
32
34
|
|
|
@@ -38,10 +40,15 @@ task :usage do
|
|
|
38
40
|
|
|
39
41
|
Default view lists session and weekly windows side by side.
|
|
40
42
|
Local Claude, Codex, and Grok sessions provide calendar day, week, and month token totals.
|
|
41
|
-
Reads Codex via `codex app-server
|
|
42
|
-
|
|
43
|
-
`~/.
|
|
44
|
-
|
|
43
|
+
Reads Codex via `codex app-server`. Grok week credits come from the
|
|
44
|
+
`cli-chat-proxy` billing API (`~/.grok/auth.json`), falling back to
|
|
45
|
+
`~/.grok/logs/unified.jsonl` for the period end; tokens still sum
|
|
46
|
+
inference_done events in that log. Claude 5h/week windows come from the
|
|
47
|
+
statusline snapshot at `~/.cache/llm/claude-limits.json`, falling back to
|
|
48
|
+
the OAuth usage API. Per-model weekly windows (Fable) get their own row and
|
|
49
|
+
come from that API either way - the snapshot doesn't carry them. `month`
|
|
50
|
+
shows Claude extra-usage credits (API only, and only when you've enabled
|
|
51
|
+
them).
|
|
45
52
|
D
|
|
46
53
|
example 'usage'
|
|
47
54
|
example 'usage month'
|
|
@@ -198,22 +205,22 @@ namespace :wrap do
|
|
|
198
205
|
-c continue the last session here claude/grok/opencode -c, codex resume --last
|
|
199
206
|
-f full permissions, no prompts --dangerously-skip-permissions, --dangerously-bypass-
|
|
200
207
|
approvals-and-sandbox, --always-approve, --auto
|
|
201
|
-
-
|
|
202
|
-
developer_instructions, grok --rules, opencode
|
|
203
|
-
inline config
|
|
208
|
+
-b bare - no AGENTS.md injection skips the default below
|
|
204
209
|
|
|
205
|
-
|
|
206
|
-
-
|
|
210
|
+
By default every AGENTS.md from ~ down to cwd is handed to the tool: claude
|
|
211
|
+
--append-system-prompt, codex -c developer_instructions, grok --rules, opencode
|
|
212
|
+
inline config. codex, grok and opencode read AGENTS.md from the git root down by
|
|
213
|
+
themselves, so they only get the files above the repo; claude gets the whole chain.
|
|
207
214
|
Anything after `--` is handed to the tool untouched. `~/bin/ai` is this command.
|
|
208
215
|
D
|
|
209
216
|
example 'wrap:run'
|
|
210
217
|
example 'wrap:run g -cf'
|
|
211
|
-
example 'wrap:run cod -
|
|
218
|
+
example 'wrap:run cod -cb'
|
|
212
219
|
example 'wrap:run c -- --model opus'
|
|
213
220
|
|
|
214
221
|
opt :continue, type: :boolean, desc: 'continue the last session in this folder'
|
|
215
222
|
opt :full, type: :boolean, desc: 'full permissions, no approval prompts'
|
|
216
|
-
opt :
|
|
223
|
+
opt :bare, type: :boolean, desc: 'bare agent - do not inject AGENTS.md'
|
|
217
224
|
|
|
218
225
|
proc do |opts|
|
|
219
226
|
require File.join(_llm_root, 'lib/llm/wrap')
|
|
@@ -225,11 +232,11 @@ namespace :wrap do
|
|
|
225
232
|
error("unknown tool #{prefix.inspect} - one of #{LlmLaunch::TOOLS.keys.join(', ')}")
|
|
226
233
|
extra = prefix ? args.drop(1) : args
|
|
227
234
|
|
|
228
|
-
launch = LlmLaunch.build(tool, extra, continue: opts[:continue], full: opts[:full],
|
|
235
|
+
launch = LlmLaunch.build(tool, extra, continue: opts[:continue], full: opts[:full], bare: opts[:bare])
|
|
229
236
|
ENV.update(launch.env)
|
|
230
237
|
say.gray "agents: #{launch.files.map { |f| f.to_s.sub(Dir.home, '~') }.join(', ')}" if launch.files.any?
|
|
231
238
|
|
|
232
|
-
flags = %i[continue full
|
|
239
|
+
flags = %i[continue full bare].select { |k| opts[k] }.map { |k| "--#{k}" }
|
|
233
240
|
origin = ['llm', 'wrap:run', tool, *flags, '--', *extra]
|
|
234
241
|
# Piped stdout is block-buffered and exec drops the buffer with the banner in it.
|
|
235
242
|
$stdout.flush
|
|
@@ -385,6 +392,98 @@ namespace :memory do
|
|
|
385
392
|
end
|
|
386
393
|
end
|
|
387
394
|
|
|
395
|
+
namespace :browser do
|
|
396
|
+
# Helpers live inside the namespace block for the same reason as in
|
|
397
|
+
# :memory - namespace subclasses do not see the recipe's root class. The
|
|
398
|
+
# recipe-level `_llm_root` local is out of reach in a def, hence the constant.
|
|
399
|
+
private
|
|
400
|
+
|
|
401
|
+
def browser_lib
|
|
402
|
+
require BROWSER_LIB
|
|
403
|
+
LlmBrowser
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
task :setup do
|
|
407
|
+
desc <<~D
|
|
408
|
+
Install Stagehand globally with bun and report what the browser tools can use.
|
|
409
|
+
|
|
410
|
+
Stagehand (@browserbasehq/stagehand) drives the installed Google Chrome over CDP.
|
|
411
|
+
Its act / observe / extract calls need a model: claude and codex answer through
|
|
412
|
+
their CLIs on your subscription, ollama through the local daemon. No API key.
|
|
413
|
+
D
|
|
414
|
+
example 'browser:setup'
|
|
415
|
+
|
|
416
|
+
proc do
|
|
417
|
+
lib = browser_lib
|
|
418
|
+
if lib.installed?
|
|
419
|
+
say.gray 'stagehand already installed'
|
|
420
|
+
else
|
|
421
|
+
say.gray lib.setup_argv.join(' ')
|
|
422
|
+
error 'bun add failed' unless system(*lib.setup_argv)
|
|
423
|
+
end
|
|
424
|
+
lib.check.each do |name, ok|
|
|
425
|
+
say "#{name.ljust(10)} #{ok ? 'ok' : 'missing'}", (ok ? :green : :red)
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
task :run do
|
|
431
|
+
desc <<~D
|
|
432
|
+
Open URL in a local Chrome and run act / observe / extract instructions in order.
|
|
433
|
+
|
|
434
|
+
Instructions are quoted strings prefixed with act:, observe: or extract:; a bare
|
|
435
|
+
string is an extract. One JSON object per instruction goes to stdout, progress to
|
|
436
|
+
stderr. Use it for other people's sites - multi-step flows, structured extraction,
|
|
437
|
+
forms and logins (--headed). For an app you develop use the chrome-devtools MCP:
|
|
438
|
+
console, network and perf live there. Nothing here sees inside a canvas.
|
|
439
|
+
|
|
440
|
+
Each model call spawns the CLI, so expect 10-20 s per instruction. Nothing is
|
|
441
|
+
cached locally. BROWSE_CLAUDE_MODEL (sonnet - haiku misses clicks), BROWSE_CODEX_MODEL
|
|
442
|
+
(codex default - set a small one) and BROWSE_OLLAMA_MODEL (qwen2.5-coder) pick the model.
|
|
443
|
+
D
|
|
444
|
+
example 'browser:run https://example.com "extract: the heading and the first link href"'
|
|
445
|
+
example 'browser:run https://x.com "act: click Sign in" "extract: the form field labels" --headed'
|
|
446
|
+
example 'browser:run https://x.com "observe: what can be clicked" --llm codex'
|
|
447
|
+
|
|
448
|
+
opt :llm, default: 'claude', positional: false, desc: 'claude | codex | ollama (prefix ok)'
|
|
449
|
+
opt :headed, type: :boolean, desc: 'visible Chrome window - logins, watching it work'
|
|
450
|
+
opt :shot, positional: false, desc: 'save a PNG of the final page to this path'
|
|
451
|
+
|
|
452
|
+
proc do |opts|
|
|
453
|
+
lib = browser_lib
|
|
454
|
+
url, *instructions = Array(opts[:args])
|
|
455
|
+
error 'usage: llm browser:run <url> ["act: ..."] ["extract: ..."] [--llm claude|codex|ollama] [--headed] [--shot out.png]' unless url
|
|
456
|
+
error 'run `llm browser:setup` first' unless lib.installed?
|
|
457
|
+
llm = lib.llm(opts[:llm]) || error("unknown llm #{opts[:llm].inspect} - one of #{lib::LLMS.join(', ')}")
|
|
458
|
+
|
|
459
|
+
$stdout.flush
|
|
460
|
+
exit(system(lib.env, *lib.run_argv(url, instructions, llm: llm, headed: opts[:headed], shot: opts[:shot])) ? 0 : 1)
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
task :script do
|
|
465
|
+
desc <<~D
|
|
466
|
+
Run an ad-hoc Stagehand script with Bun, Stagehand resolvable from the global store.
|
|
467
|
+
|
|
468
|
+
The script imports open() from recipes/lib/llm/browser/lib.ts and gets a ready
|
|
469
|
+
{ stagehand, page, close }. Anything after the file is left in process.argv.
|
|
470
|
+
D
|
|
471
|
+
example 'browser:script ./scrape.ts'
|
|
472
|
+
example 'browser:script ./scrape.ts -- --since 2026-01-01'
|
|
473
|
+
|
|
474
|
+
proc do |opts|
|
|
475
|
+
lib = browser_lib
|
|
476
|
+
path, *extra = Array(opts[:args])
|
|
477
|
+
error 'usage: llm browser:script <file.ts> [args...]' unless path
|
|
478
|
+
error "no such file: #{path}" unless File.file?(File.expand_path(path))
|
|
479
|
+
error 'run `llm browser:setup` first' unless lib.installed?
|
|
480
|
+
|
|
481
|
+
$stdout.flush
|
|
482
|
+
exec(lib.env, *lib.script_argv(path, extra))
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
|
|
388
487
|
namespace :prompt do
|
|
389
488
|
TOKEN_PATTERN ||= /[a-z0-9_-]+/.freeze
|
|
390
489
|
TOKEN_LINE_RE ||= /\A(?:\s*:[a-z0-9_-]+)+\s*\z/.freeze
|
|
@@ -643,9 +742,10 @@ namespace :prompt do
|
|
|
643
742
|
D
|
|
644
743
|
opt :claude, type: :boolean, default: false, desc: 'Claude Code hook mode'
|
|
645
744
|
opt :codex, type: :boolean, default: false, desc: 'Codex hook mode'
|
|
745
|
+
opt :grok, type: :boolean, default: false, desc: 'Grok CLI hook mode'
|
|
646
746
|
|
|
647
747
|
proc do |opts|
|
|
648
|
-
error '--claude or --
|
|
748
|
+
error '--claude, --codex or --grok required' unless opts[:claude] || opts[:codex] || opts[:grok]
|
|
649
749
|
|
|
650
750
|
raw = opts[:stdin].to_s
|
|
651
751
|
prompt = begin
|
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.
|
|
4
|
+
version: 0.3.29
|
|
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-09-
|
|
11
|
+
date: 2026-09-10 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: minitest
|
|
@@ -55,6 +55,7 @@ files:
|
|
|
55
55
|
- "./lib/lux-hammer.rb"
|
|
56
56
|
- "./recipes/deploy.rb"
|
|
57
57
|
- "./recipes/git-helper.rb"
|
|
58
|
+
- "./recipes/lib/llm/browser.rb"
|
|
58
59
|
- "./recipes/lib/llm/launch.rb"
|
|
59
60
|
- "./recipes/lib/llm/plan.rb"
|
|
60
61
|
- "./recipes/lib/llm/usage.rb"
|