lux-hammer 0.3.26 → 0.3.28

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: acc942b0818d0cc60a8cbe9a55fbf092665d6efcb46df209b2a8e9721aa1e190
4
- data.tar.gz: 94baed7a0025611360d0315d084fd7432a1eb68d84302bfeaf0960ee0326aee7
3
+ metadata.gz: 48fd3473ba922f93fa0ebcc897d0bf6a88ec78283ed4436281d938a1297e53ac
4
+ data.tar.gz: 30eef033e89155cd82b362563e5f4dea0716551f56d686ae5c1fde16fc23170c
5
5
  SHA512:
6
- metadata.gz: 486bb304e25a56faada26757651998d75e589fabca7402f499dc9cdd3228a90b30cc3fc7cda765d2cd34fc90eecc906208681c031e38628e0087f59eb5250af7
7
- data.tar.gz: 87115686cf4bbdd6a47ed9a25e048cf811fe1c8071966e5ee3143b7fcafc286ff1480d00c5ba494e407fa9032b8e1b6dda43e8623c011ae9ffdbdf70e8a33612
6
+ metadata.gz: 7e3961d9aa6c6d89081ec6e1ee111346fab1944c8152644c7e22b300815bea3152606459c6d90e88592c8933021cc7c8ba75b262f8c5aebc4105b05816c0b9cb
7
+ data.tar.gz: 50fd5ecc020ecb0d31ad7f79d7d7dc5ae313e6646c30cdc0758b07bfbad1bfe145f50301215853b168cd3d79ccd5d66f0372551f405984ac87b0d026f13d047f
data/.version CHANGED
@@ -1 +1 @@
1
- 0.3.26
1
+ 0.3.28
data/AGENTS.md CHANGED
@@ -22,8 +22,9 @@ usable as a library (`require 'lux-hammer'`, subclass `Hammer`, call
22
22
  monkey-patches.
23
23
  * **Zero runtime dependencies**. The gem must work with stdlib only. New
24
24
  dependencies require explicit user approval.
25
- * **Ruby >= 2.7**. Do not use language features introduced after 2.7
26
- without flagging.
25
+ * **Ruby >= 2.7** for `lib/` and `bin/`. Do not use language features
26
+ introduced after 2.7 there without flagging. Bundled recipes may sit
27
+ higher - the `llm` recipe needs >= 3.1 (endless methods, shorthand hash).
27
28
  * **`desc` is single-arg, multi-line allowed**. The argument is one
28
29
  string; it may contain newlines. The first line is the brief shown in
29
30
  command listings, the full string renders (indented) in per-command
@@ -244,12 +245,15 @@ explicit ADR-level discussion. Keys:
244
245
 
245
246
  ## Help formatting
246
247
 
247
- * Program name in usage lines is computed automatically (invocation path
248
- relative to cwd if the bin lives inside cwd, otherwise the basename of
249
- `$PROGRAM_NAME`). There is no user-facing override; the auto-detection
250
- is the whole API. `Hammer.cli` warms the cache before chdir-ing into
251
- the Hammerfile's directory so the resolved name stays relative to the
252
- 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.
253
257
  * `hammer` (no args) routes to the `:default` built-in task, which
254
258
  falls through to `print_help(extended: false)` - the brief command
255
259
  listing with a top gray `lux-hammer VERSION - <homepage>` banner.
@@ -300,7 +304,10 @@ Task contracts:
300
304
  `--path NAME`, `--edit NAME`, `--run NAME [ARGS]` (use `--` to
301
305
  forward flags through). Bare invocation lists.
302
306
  * `h:init` - writes `Hammer::STARTER_HAMMERFILE` to `./Hammerfile`;
303
- refuses if one exists.
307
+ refuses if one exists. `--script` writes `Hammer::STARTER_SCRIPT`
308
+ (shebang + one `hello` task) to stdout instead, or to a positional
309
+ TARGET with mode 0755. Both templates are single-quoted heredocs in
310
+ `lib/lux-hammer.rb` - the one source of truth, also rendered in help.
304
311
  * `h:json` - `puts JSON` of `root.export_spec` (tasks grouped exactly
305
312
  like the bare listing via `section_for`, root group keyed `__root`).
306
313
  `--all` keeps the `h:` tree, `--compact` minifies.
@@ -427,7 +434,8 @@ new code in this gem should show.
427
434
 
428
435
  ## Testing
429
436
 
430
- * Run: `bundle exec rake test`
437
+ * Run: `bin/hammer test` (the `:test` task in this repo's Hammerfile -
438
+ there is no Rakefile)
431
439
  * Single file: `bundle exec ruby -Ilib -Itest test/parser_test.rb`
432
440
  * Single test by name: `... test/parser_test.rb -n test_boolean_via_short_alias`
433
441
  * Helper `CaptureIO` in `test/test_helper.rb` swaps `$stdout`/`$stderr`
data/README.md CHANGED
@@ -634,6 +634,9 @@ Under the `h:` namespace:
634
634
  * `h:version` - print the lux-hammer version.
635
635
  * `h:recipes` - list / install / show / edit recipes.
636
636
  * `h:init` - write a starter Hammerfile in cwd (refuses if one exists).
637
+ `--script` prints a minimal standalone CLI to stdout instead (shebang
638
+ plus one `hello` task) - pipe it to a file and `chmod +x`, or name a
639
+ target to write and chmod in one step.
637
640
  * `h:json` - dump the CLI definition as JSON (tasks grouped like the
638
641
  bare listing, with desc/options/examples/aliases/needs/cron); `--all`
639
642
  includes the `h:` tasks, `--compact` minifies. Output is plain stdout
@@ -1135,7 +1138,16 @@ end
1135
1138
  ### `#!/usr/bin/env hammer` (single-file scripts)
1136
1139
 
1137
1140
  For a one-off CLI that lives as a single executable file, point the
1138
- shebang straight at `hammer` - no `require`, no boilerplate:
1141
+ shebang straight at `hammer` - no `require`, no boilerplate. Start from
1142
+ the bundled template:
1143
+
1144
+ ```sh
1145
+ $ hammer h:init --script greet # or: h:init --script > greet
1146
+ $ ./greet hello
1147
+ Hello from Hammer
1148
+ ```
1149
+
1150
+ Then fill it in - the body is plain Hammerfile DSL:
1139
1151
 
1140
1152
  ```ruby
1141
1153
  #!/usr/bin/env hammer
@@ -1398,7 +1410,7 @@ MyCli.hammer :greet, loud: true
1398
1410
  git clone https://github.com/dux/hammer
1399
1411
  cd lux-hammer
1400
1412
  bundle install
1401
- bundle exec rake test
1413
+ bin/hammer test
1402
1414
  ```
1403
1415
 
1404
1416
  Tests live in `test/` and use minitest. Run a single file with
@@ -108,8 +108,24 @@ class Hammer
108
108
  def register_init(klass)
109
109
  klass.class_eval do
110
110
  task :init do
111
- desc 'Write a starter Hammerfile in the current directory'
112
- proc { Hammer::Builtins.write_starter_hammerfile }
111
+ desc <<~TXT
112
+ Write a starter Hammerfile in the current directory.
113
+
114
+ --script prints a minimal standalone CLI (shebang + one `hello`
115
+ task) to stdout instead - pipe it to a file, chmod +x, run it.
116
+ Name a TARGET to write and chmod in one step.
117
+ TXT
118
+ example 'h:init'
119
+ example 'h:init --script > mytool && chmod +x mytool'
120
+ example 'h:init --script mytool'
121
+ opt :script, type: :boolean, alias: :s, desc: 'print a standalone executable CLI (TARGET writes + chmod +x)'
122
+ proc do |opts|
123
+ if opts[:script]
124
+ Hammer::Builtins.write_starter_script(opts[:args].first)
125
+ else
126
+ Hammer::Builtins.write_starter_hammerfile
127
+ end
128
+ end
113
129
  end
114
130
  end
115
131
  end
@@ -237,6 +253,20 @@ class Hammer
237
253
  Shell.say "created #{target}", :green
238
254
  end
239
255
 
256
+ # `h:init --script`. No target -> stdout, so `> tool` works; with a
257
+ # target it lands executable. Never clobbers, same as the Hammerfile.
258
+ def write_starter_script(target = nil)
259
+ return puts(Hammer::STARTER_SCRIPT) unless target
260
+
261
+ if File.exist?(target)
262
+ Shell.print_error "#{target} already exists"
263
+ exit 1
264
+ end
265
+ File.write(target, Hammer::STARTER_SCRIPT)
266
+ File.chmod(0o755, target)
267
+ Shell.say "created #{target} (chmod +x)", :green
268
+ end
269
+
240
270
  # Implementations of the `recipes` task's action flags, plus the
241
271
  # no-flag listing view. Separate module so the task definition above
242
272
  # stays small.
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
@@ -1118,6 +1113,20 @@ class Hammer
1118
1113
  end
1119
1114
  RUBY
1120
1115
 
1116
+ # Minimal standalone CLI, dumped by `hammer h:init --script`. The
1117
+ # shebang is what `shebang_script` sniffs for, so `chmod +x` is all
1118
+ # that stands between this and a working binary.
1119
+ STARTER_SCRIPT ||= <<~'RUBY'
1120
+ #!/usr/bin/env hammer
1121
+
1122
+ desc 'My standalone CLI'
1123
+
1124
+ task :hello do
1125
+ desc 'Print a greeting'
1126
+ proc { say.green 'Hello from Hammer' }
1127
+ end
1128
+ RUBY
1129
+
1121
1130
  # Default install dir used by install.sh and `hammer h:update`.
1122
1131
  SELF_UPDATE_DIR ||= File.expand_path('~/.local/share/lux-hammer')
1123
1132
  SELF_UPDATE_REPO ||= 'https://github.com/dux/hammer.git'
@@ -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.
@@ -378,15 +383,15 @@ module LlmUsage
378
383
  return nil unless data.is_a?(Hash)
379
384
 
380
385
  config = data['config'] || data
381
- week_pct = config['creditUsagePercent']
382
- week_reset = config['billingPeriodEnd'] || config.dig('currentPeriod', 'end')
383
386
  build = Array(config['productUsage']).find { |p| p['product'] == 'GrokBuild' }
387
+ week_pct = (build && build['usagePercent']) || config['creditUsagePercent']
388
+ week_reset = config['billingPeriodEnd'] || config.dig('currentPeriod', 'end')
384
389
 
385
390
  UsageRow.new(
386
391
  name: 'Grok',
387
392
  session_pct: '-',
388
393
  session_reset: '-',
389
- week_pct: format_pct(build ? build['usagePercent'] : week_pct),
394
+ week_pct: format_pct(week_pct),
390
395
  week_reset: format_reset_short(week_reset, now: now)
391
396
  )
392
397
  end
@@ -496,8 +501,59 @@ module LlmUsage
496
501
  [nil, note || fallback_note || 'codex: no local rate limits']
497
502
  end
498
503
 
499
- def fetch_grok_usage
500
- grok_log_billing_config
504
+ def fetch_grok_usage(now: Time.now, path: GROK_AUTH_PATH)
505
+ data, note = fetch_grok_oauth_usage(now: now, path: path)
506
+ return [data, note] if data
507
+
508
+ log_data, log_note = grok_log_billing_config
509
+ return [log_data, log_note] if log_data
510
+
511
+ [nil, [note, log_note].compact.join('; ')]
512
+ end
513
+
514
+ def fetch_grok_oauth_usage(now: Time.now, path: GROK_AUTH_PATH)
515
+ token = grok_oauth_token(now: now, path: path)
516
+ return [nil, 'grok: no usable auth token (run `grok login`)'] unless token
517
+
518
+ uri = URI(GROK_BILLING_URL)
519
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 3, read_timeout: 5) do |http|
520
+ http.get(
521
+ uri.request_uri,
522
+ 'Authorization' => "Bearer #{token}",
523
+ 'Accept' => 'application/json',
524
+ 'X-XAI-Token-Auth' => GROK_TOKEN_AUTH
525
+ )
526
+ end
527
+ return [nil, "grok: billing API returned #{response.code}"] unless response.is_a?(Net::HTTPSuccess)
528
+
529
+ data = JSON.parse(response.body)
530
+ return [nil, 'grok: billing API returned no config'] unless data.is_a?(Hash) && data['config'].is_a?(Hash)
531
+
532
+ [data, nil]
533
+ rescue JSON::ParserError
534
+ [nil, 'grok: billing API returned malformed JSON']
535
+ rescue StandardError => e
536
+ [nil, "grok: billing API unreachable (#{e.message})"]
537
+ end
538
+
539
+ # grok login writes one Hash per issuer (`https://auth.x.ai::<uuid>`).
540
+ # An expired token is treated as absent rather than refreshed here.
541
+ def grok_oauth_token(now: Time.now, path: GROK_AUTH_PATH)
542
+ creds = read_json(path)
543
+ return nil unless creds.is_a?(Hash)
544
+
545
+ creds.each_value do |entry|
546
+ next unless entry.is_a?(Hash)
547
+
548
+ token = entry['key']
549
+ next if token.nil? || token.empty?
550
+
551
+ expires_at = parse_time(entry['expires_at'])
552
+ next if expires_at && expires_at <= now
553
+
554
+ return token
555
+ end
556
+ nil
501
557
  end
502
558
 
503
559
  def codex_app_server_rate_limits
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,13 @@ 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. `month` shows Claude extra-usage credits (API only,
49
+ and only when you've enabled them).
45
50
  D
46
51
  example 'usage'
47
52
  example 'usage month'
@@ -198,22 +203,22 @@ namespace :wrap do
198
203
  -c continue the last session here claude/grok/opencode -c, codex resume --last
199
204
  -f full permissions, no prompts --dangerously-skip-permissions, --dangerously-bypass-
200
205
  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
206
+ -b bare - no AGENTS.md injection skips the default below
204
207
 
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.
208
+ By default every AGENTS.md from ~ down to cwd is handed to the tool: claude
209
+ --append-system-prompt, codex -c developer_instructions, grok --rules, opencode
210
+ inline config. codex, grok and opencode read AGENTS.md from the git root down by
211
+ themselves, so they only get the files above the repo; claude gets the whole chain.
207
212
  Anything after `--` is handed to the tool untouched. `~/bin/ai` is this command.
208
213
  D
209
214
  example 'wrap:run'
210
215
  example 'wrap:run g -cf'
211
- example 'wrap:run cod -ca'
216
+ example 'wrap:run cod -cb'
212
217
  example 'wrap:run c -- --model opus'
213
218
 
214
219
  opt :continue, type: :boolean, desc: 'continue the last session in this folder'
215
220
  opt :full, type: :boolean, desc: 'full permissions, no approval prompts'
216
- opt :agents, type: :boolean, desc: 'inject AGENTS.md found from ~ down to cwd'
221
+ opt :bare, type: :boolean, desc: 'bare agent - do not inject AGENTS.md'
217
222
 
218
223
  proc do |opts|
219
224
  require File.join(_llm_root, 'lib/llm/wrap')
@@ -225,11 +230,11 @@ namespace :wrap do
225
230
  error("unknown tool #{prefix.inspect} - one of #{LlmLaunch::TOOLS.keys.join(', ')}")
226
231
  extra = prefix ? args.drop(1) : args
227
232
 
228
- launch = LlmLaunch.build(tool, extra, continue: opts[:continue], full: opts[:full], agents: opts[:agents])
233
+ launch = LlmLaunch.build(tool, extra, continue: opts[:continue], full: opts[:full], bare: opts[:bare])
229
234
  ENV.update(launch.env)
230
235
  say.gray "agents: #{launch.files.map { |f| f.to_s.sub(Dir.home, '~') }.join(', ')}" if launch.files.any?
231
236
 
232
- flags = %i[continue full agents].select { |k| opts[k] }.map { |k| "--#{k}" }
237
+ flags = %i[continue full bare].select { |k| opts[k] }.map { |k| "--#{k}" }
233
238
  origin = ['llm', 'wrap:run', tool, *flags, '--', *extra]
234
239
  # Piped stdout is block-buffered and exec drops the buffer with the banner in it.
235
240
  $stdout.flush
@@ -385,6 +390,98 @@ namespace :memory do
385
390
  end
386
391
  end
387
392
 
393
+ namespace :browser do
394
+ # Helpers live inside the namespace block for the same reason as in
395
+ # :memory - namespace subclasses do not see the recipe's root class. The
396
+ # recipe-level `_llm_root` local is out of reach in a def, hence the constant.
397
+ private
398
+
399
+ def browser_lib
400
+ require BROWSER_LIB
401
+ LlmBrowser
402
+ end
403
+
404
+ task :setup do
405
+ desc <<~D
406
+ Install Stagehand globally with bun and report what the browser tools can use.
407
+
408
+ Stagehand (@browserbasehq/stagehand) drives the installed Google Chrome over CDP.
409
+ Its act / observe / extract calls need a model: claude and codex answer through
410
+ their CLIs on your subscription, ollama through the local daemon. No API key.
411
+ D
412
+ example 'browser:setup'
413
+
414
+ proc do
415
+ lib = browser_lib
416
+ if lib.installed?
417
+ say.gray 'stagehand already installed'
418
+ else
419
+ say.gray lib.setup_argv.join(' ')
420
+ error 'bun add failed' unless system(*lib.setup_argv)
421
+ end
422
+ lib.check.each do |name, ok|
423
+ say "#{name.ljust(10)} #{ok ? 'ok' : 'missing'}", (ok ? :green : :red)
424
+ end
425
+ end
426
+ end
427
+
428
+ task :run do
429
+ desc <<~D
430
+ Open URL in a local Chrome and run act / observe / extract instructions in order.
431
+
432
+ Instructions are quoted strings prefixed with act:, observe: or extract:; a bare
433
+ string is an extract. One JSON object per instruction goes to stdout, progress to
434
+ stderr. Use it for other people's sites - multi-step flows, structured extraction,
435
+ forms and logins (--headed). For an app you develop use the chrome-devtools MCP:
436
+ console, network and perf live there. Nothing here sees inside a canvas.
437
+
438
+ Each model call spawns the CLI, so expect 10-20 s per instruction. Nothing is
439
+ cached locally. BROWSE_CLAUDE_MODEL (sonnet - haiku misses clicks), BROWSE_CODEX_MODEL
440
+ (codex default - set a small one) and BROWSE_OLLAMA_MODEL (qwen2.5-coder) pick the model.
441
+ D
442
+ example 'browser:run https://example.com "extract: the heading and the first link href"'
443
+ example 'browser:run https://x.com "act: click Sign in" "extract: the form field labels" --headed'
444
+ example 'browser:run https://x.com "observe: what can be clicked" --llm codex'
445
+
446
+ opt :llm, default: 'claude', positional: false, desc: 'claude | codex | ollama (prefix ok)'
447
+ opt :headed, type: :boolean, desc: 'visible Chrome window - logins, watching it work'
448
+ opt :shot, positional: false, desc: 'save a PNG of the final page to this path'
449
+
450
+ proc do |opts|
451
+ lib = browser_lib
452
+ url, *instructions = Array(opts[:args])
453
+ error 'usage: llm browser:run <url> ["act: ..."] ["extract: ..."] [--llm claude|codex|ollama] [--headed] [--shot out.png]' unless url
454
+ error 'run `llm browser:setup` first' unless lib.installed?
455
+ llm = lib.llm(opts[:llm]) || error("unknown llm #{opts[:llm].inspect} - one of #{lib::LLMS.join(', ')}")
456
+
457
+ $stdout.flush
458
+ exit(system(lib.env, *lib.run_argv(url, instructions, llm: llm, headed: opts[:headed], shot: opts[:shot])) ? 0 : 1)
459
+ end
460
+ end
461
+
462
+ task :script do
463
+ desc <<~D
464
+ Run an ad-hoc Stagehand script with Bun, Stagehand resolvable from the global store.
465
+
466
+ The script imports open() from recipes/lib/llm/browser/lib.ts and gets a ready
467
+ { stagehand, page, close }. Anything after the file is left in process.argv.
468
+ D
469
+ example 'browser:script ./scrape.ts'
470
+ example 'browser:script ./scrape.ts -- --since 2026-01-01'
471
+
472
+ proc do |opts|
473
+ lib = browser_lib
474
+ path, *extra = Array(opts[:args])
475
+ error 'usage: llm browser:script <file.ts> [args...]' unless path
476
+ error "no such file: #{path}" unless File.file?(File.expand_path(path))
477
+ error 'run `llm browser:setup` first' unless lib.installed?
478
+
479
+ $stdout.flush
480
+ exec(lib.env, *lib.script_argv(path, extra))
481
+ end
482
+ end
483
+ end
484
+
388
485
  namespace :prompt do
389
486
  TOKEN_PATTERN ||= /[a-z0-9_-]+/.freeze
390
487
  TOKEN_LINE_RE ||= /\A(?:\s*:[a-z0-9_-]+)+\s*\z/.freeze
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.26
4
+ version: 0.3.28
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-08-27 00:00:00.000000000 Z
11
+ date: 2026-09-09 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"