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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cbcb0355f0ce770acb1ace879a1567ee1193de1da5ac6e6c32e36faa8f2a48fe
4
- data.tar.gz: 8299d3bf2a47844e0e5c01ba73225d43be8aef718ceee3cf9603d0ec85e3e582
3
+ metadata.gz: 6dab0deb8eba80ec1aac72b16620370af2d80d2528641e9117be71df84e8d416
4
+ data.tar.gz: 6460bbda170ba18cedf234846e4898f1c6efc48732c81ed122fc6eaa11d237b8
5
5
  SHA512:
6
- metadata.gz: 6f5a7f793308a5f55e1bdb99ab85ae7aa8b271c92a073f33e1e7130f757395a97071da89a0933958cd89279a7a2004372a71746f1d83c945747ea1cb785f9f75
7
- data.tar.gz: b5ee6d077d5b14fd5c448a28ea53e15da59198c067a568bee97193b5352cfea78dd073dff9054af58501453a72cbdae5b0d6c6af5e3e578004b102434a98b29d
6
+ metadata.gz: c24c74422db1ac9a72b5a03ae4f7d4e56e486c7622be40f5e136c16b9a15b2a04feeb7d2f61cdfdaaeafbc5c3913e71c2f8faf567cac8ea236eaeeadcb7fcb4f
7
+ data.tar.gz: 823288d67fd112a118c09b3b1dd3599a3b080c5c74fd6e5a35492be182a72fa3fbe82359498e1facb0396b61773b1a51e61dba786284d2bde19426a8e3eddc81
data/.version CHANGED
@@ -1 +1 @@
1
- 0.3.27
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 computed automatically (invocation path
249
- relative to cwd if the bin lives inside cwd, otherwise the basename of
250
- `$PROGRAM_NAME`). There is no user-facing override; the auto-detection
251
- is the whole API. `Hammer.cli` warms the cache before chdir-ing into
252
- the Hammerfile's directory so the resolved name stays relative to the
253
- cwd the user invoked from.
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: `bundle exec rake test`
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
- bundle exec rake test
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 path relative to cwd
142
- # if the script lives inside it (e.g. `bin/foo` when invoked from the
143
- # project root), otherwise the basename (e.g. `lux` for a globally
144
- # installed bin in PATH).
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) unless prog.include?('/')
148
- # Resolve symlinks on both sides so e.g. macOS `/tmp` -> `/private/tmp`
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
@@ -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 / -a switches to what that CLI takes.
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; -a only adds the
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, agents: false, cwd: Dir.pwd, home: Dir.home)
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 = agents ? agents_files(tool, cwd: cwd, home: home) : []
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))
@@ -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(build ? build['usagePercent'] : week_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
- grok_log_billing_config
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
- if data
634
- FileUtils.mkdir_p(CACHE_DIR)
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 -a switches for all
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`; Grok billing + tokens from `~/.grok/logs/unified.jsonl`
42
- (inference_done events). Claude 5h/week windows come from the statusline snapshot at
43
- `~/.cache/llm/claude-limits.json`, falling back to the OAuth usage API. `month` shows
44
- Claude extra-usage credits (API only, and only when you've enabled them).
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
- -a AGENTS.md from ~ down to cwd claude --append-system-prompt, codex -c
202
- developer_instructions, grok --rules, opencode
203
- inline config
208
+ -b bare - no AGENTS.md injection skips the default below
204
209
 
205
- codex, grok and opencode read AGENTS.md from the git root down by themselves, so
206
- -a only adds the files above the repo for them; claude gets the whole chain.
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 -ca'
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 :agents, type: :boolean, desc: 'inject AGENTS.md found from ~ down to cwd'
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], agents: opts[:agents])
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 agents].select { |k| opts[k] }.map { |k| "--#{k}" }
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 --codex required' unless opts[:claude] || opts[:codex]
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.27
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-08 00:00:00.000000000 Z
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"