lux-hammer 0.3.28 → 0.3.30

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: 48fd3473ba922f93fa0ebcc897d0bf6a88ec78283ed4436281d938a1297e53ac
4
- data.tar.gz: 30eef033e89155cd82b362563e5f4dea0716551f56d686ae5c1fde16fc23170c
3
+ metadata.gz: 2ca175d7445c386d0c8bd3be6834b057e6b958ee3d0de48d72e49a53ef4da776
4
+ data.tar.gz: 3303a15ee74702d81ebfba70d15366870bfb8dccc2d5b0fc8c5304e2fe28ef0e
5
5
  SHA512:
6
- metadata.gz: 7e3961d9aa6c6d89081ec6e1ee111346fab1944c8152644c7e22b300815bea3152606459c6d90e88592c8933021cc7c8ba75b262f8c5aebc4105b05816c0b9cb
7
- data.tar.gz: 50fd5ecc020ecb0d31ad7f79d7d7dc5ae313e6646c30cdc0758b07bfbad1bfe145f50301215853b168cd3d79ccd5d66f0372551f405984ac87b0d026f13d047f
6
+ metadata.gz: d925611cf831aa0e6c4198d4761bdcb04b7454afab82a8cc12100130e23dc63139f3bad8fbf0473cd67f7bf71ea46ef3ceeb91e266f6bddba51f4097469231dc
7
+ data.tar.gz: a489e910f4027422474b1741a0ff45e4e5729009af2d5c6ba7fbf79a7f11661e547b9947058c6b5c237e41fbecfc5baa9a2a2686158a95460a0c2be7ba4bf329
data/.version CHANGED
@@ -1 +1 @@
1
- 0.3.28
1
+ 0.3.30
data/lib/lux-hammer.rb CHANGED
@@ -1281,10 +1281,12 @@ class Hammer
1281
1281
  # built-ins (so the script's `--help` shows only what it defines).
1282
1282
  # `program_name` is the script's basename so help reads "myscript foo"
1283
1283
  # rather than "hammer foo" - works even when invoked via a symlink in
1284
- # PATH, since argv[0] is the path the user typed.
1284
+ # PATH, since argv[0] is the path the user typed. `.rb` is stripped so
1285
+ # a recipe exec'd as `recipes/llm.rb` (typical `~/bin/llm` stub) still
1286
+ # banners as `llm`, matching `Hammer.recipe(:llm)`.
1285
1287
  def self.run_shebang(path, argv)
1286
1288
  klass = Class.new(Hammer)
1287
- klass.instance_variable_set(:@program_name, File.basename(path))
1289
+ klass.instance_variable_set(:@program_name, File.basename(path, '.rb'))
1288
1290
  Builder.new(klass).evaluate(File.read(path), path)
1289
1291
  klass.start(argv)
1290
1292
  end
@@ -450,6 +450,26 @@ task :pr do
450
450
  proc { open_in_browser(remote_pr_url) }
451
451
  end
452
452
 
453
+ task :prs do
454
+ desc 'list open PRs: id, author, short title, url (others first, then mine)'
455
+ proc do |_|
456
+ require 'json'
457
+ out = `GH_PAGER=cat gh pr list --state open --limit 100 --json number,title,url,author`
458
+ error 'gh pr list failed' unless $?.success?
459
+ prs = JSON.parse(out)
460
+ next say('No open PRs', :yellow) if prs.empty?
461
+ me = `GH_PAGER=cat gh api user --jq .login`.chomp
462
+ mine, others = prs.partition { |pr| pr.dig('author', 'login') == me }
463
+ author_width = prs.map { |pr| pr.dig('author', 'login').to_s.length }.max
464
+ (others + mine).each do |pr|
465
+ title = pr['title'].length > 50 ? "#{pr['title'][0, 49]}…" : pr['title']
466
+ author = pr.dig('author', 'login').to_s.ljust(author_width)
467
+ author = author.color(mine.include?(pr) ? :green : :blue)
468
+ puts "#{"##{pr['number']}".ljust(7).color(:yellow)} #{author} #{title.ljust(50)} #{pr['url']}"
469
+ end
470
+ end
471
+ end
472
+
453
473
  # ---- tags / users / stats --------------------------------------------
454
474
 
455
475
  task :tag do
@@ -9,17 +9,24 @@ require 'pathname'
9
9
  module LlmLaunch
10
10
  Launch = Struct.new(:argv, :env, :files)
11
11
 
12
+ # The one registry of agent CLIs. :continue / :full / :agents drive a launch;
13
+ # :update / :version / :models drive `llm wrap:run --update` (LlmUpdate).
12
14
  # :agents gets the combined AGENTS.md text and the file paths, and answers
13
15
  # with the argv to add and/or env to set - opencode has no flag for
14
16
  # instructions, only inline config, and that one takes paths.
17
+ # :models is nil where the CLI has no listing command - skip, not "none".
15
18
  TOOLS ||= {
16
19
  'claude' => { continue: %w[-c], full: %w[--dangerously-skip-permissions],
20
+ update: %w[claude update], version: %w[claude --version], models: nil,
17
21
  agents: ->(text, _files) { { args: ['--append-system-prompt', text] } } },
18
22
  'codex' => { continue: %w[resume --last], full: %w[--dangerously-bypass-approvals-and-sandbox],
23
+ update: %w[codex update], version: %w[codex --version], models: nil,
19
24
  agents: ->(text, _files) { { args: ['-c', "developer_instructions=#{text}"] } } },
20
25
  'grok' => { continue: %w[-c], full: %w[--always-approve],
26
+ update: %w[grok update], version: %w[grok version], models: %w[grok models],
21
27
  agents: ->(text, _files) { { args: ['--rules', text] } } },
22
28
  'opencode' => { continue: %w[-c], full: %w[--auto],
29
+ update: %w[opencode upgrade], version: %w[opencode --version], models: %w[opencode models],
23
30
  agents: ->(_text, files) { { env: { 'OPENCODE_CONFIG_CONTENT' => { instructions: files }.to_json } } } },
24
31
  }.freeze
25
32
 
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+ require_relative 'launch'
6
+
7
+ # Backs `llm wrap:run --update` (`ai --update`): runs each agent CLI's own
8
+ # updater and reports old -> new, then re-reads the model lists and shows what
9
+ # appeared or disappeared since the last run. None of these tools download
10
+ # model weights - the models are API-side, so the list is the thing that moves.
11
+ module LlmUpdate
12
+ extend Hammer::Shell
13
+
14
+ CACHE_PATH ||= File.expand_path('~/.cache/llm/models.json')
15
+ WIDTH ||= LlmLaunch::TOOLS.keys.map(&:size).max
16
+
17
+ # How each CLI prints its list: grok bullets it under a preamble ("You are
18
+ # not authenticated." when logged out) and marks the default, opencode prints
19
+ # one bare provider/model per line.
20
+ MODEL_RE ||= {
21
+ 'grok' => /\A\s*[*-]\s+(\S+)/,
22
+ 'opencode' => %r{\A(\S+/\S+)\z},
23
+ }.freeze
24
+
25
+ module_function
26
+
27
+ # Returns the exit status - non-zero if any updater failed.
28
+ def run(tools)
29
+ rows = tools.map { |tool| update_one(tool) }
30
+ say ''
31
+ rows.each { |row| report(row) }
32
+ report_models(rows.select { |row| row[:installed] }.map { |row| row[:tool] })
33
+ rows.any? { |row| row[:failed] } ? 1 : 0
34
+ end
35
+
36
+ def update_one(tool)
37
+ return { tool: tool, installed: false } unless command?(tool)
38
+
39
+ argv = LlmLaunch::TOOLS.fetch(tool)[:update]
40
+ before = version(tool)
41
+ say.gray "$ #{argv.join(' ')}"
42
+ ok = system(*argv)
43
+ { tool: tool, installed: true, before: before, after: version(tool), failed: !ok }
44
+ end
45
+
46
+ def report(row)
47
+ name = row[:tool].ljust(WIDTH)
48
+ return say.gray("* #{name} not installed") unless row[:installed]
49
+ return say.red("* #{name} update failed") if row[:failed]
50
+
51
+ before, after = row.values_at(:before, :after)
52
+ if before && after && before != after
53
+ say.green "* #{name} #{before} -> #{after}"
54
+ elsif after
55
+ say "* #{name} #{after} (latest)"
56
+ else
57
+ say "* #{name} #{before || 'unknown version'}"
58
+ end
59
+ end
60
+
61
+ def report_models(tools)
62
+ tools = tools.select { |tool| LlmLaunch::TOOLS.fetch(tool)[:models] }
63
+ return if tools.empty?
64
+
65
+ cache = cache_read
66
+ say ''
67
+ say 'models'
68
+ tools.each { |tool| cache[tool] = report_model_list(tool, cache[tool]) }
69
+ cache_write(cache)
70
+ end
71
+
72
+ # Prints one tool's list against the previously cached one, and answers with
73
+ # the fresh list so the caller can write it back.
74
+ def report_model_list(tool, old)
75
+ list = models(tool)
76
+ name = tool.ljust(WIDTH)
77
+ if list.empty?
78
+ say.gray " #{name} no models listed"
79
+ return old || []
80
+ end
81
+
82
+ say " #{name} #{list.size} models"
83
+ unless old
84
+ say.gray ' (first run)'
85
+ return list
86
+ end
87
+
88
+ added, removed = diff(old, list)
89
+ say.gray(' unchanged') if added.empty? && removed.empty?
90
+ added.each { |model| say.green " + #{model}" }
91
+ removed.each { |model| say.gray " - #{model}" }
92
+ list
93
+ end
94
+
95
+ def version(tool)
96
+ parse_version(capture(LlmLaunch::TOOLS.fetch(tool)[:version]))
97
+ end
98
+
99
+ def models(tool)
100
+ parse_models(tool, capture(LlmLaunch::TOOLS.fetch(tool)[:models]))
101
+ end
102
+
103
+ # `2.1.268 (Claude Code)`, `codex-cli 0.154.0`, `grok 1.0.25 (f7e67d6) [stable]`, `1.18.30`
104
+ def parse_version(out)
105
+ out.to_s.lines.first.to_s[/\d+\.\d+(?:\.\d+)*/]
106
+ end
107
+
108
+ def parse_models(tool, out)
109
+ re = MODEL_RE[tool]
110
+ return [] unless re
111
+ out.to_s.lines.filter_map { |line| line.rstrip[re, 1] }.uniq.sort
112
+ end
113
+
114
+ def diff(old, new)
115
+ [new - old, old - new]
116
+ end
117
+
118
+ def cache_read
119
+ JSON.parse(File.read(CACHE_PATH))
120
+ rescue StandardError
121
+ {}
122
+ end
123
+
124
+ def cache_write(data)
125
+ FileUtils.mkdir_p(File.dirname(CACHE_PATH))
126
+ tmp = "#{CACHE_PATH}.#{Process.pid}.tmp"
127
+ File.write(tmp, JSON.pretty_generate(data))
128
+ File.rename(tmp, CACHE_PATH)
129
+ end
130
+
131
+ def capture(argv)
132
+ IO.popen(argv, err: %i[child out], &:read).to_s
133
+ rescue StandardError
134
+ ''
135
+ end
136
+
137
+ def command?(name)
138
+ ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).any? { |dir| File.executable?(File.join(dir, name)) }
139
+ end
140
+ end
@@ -64,7 +64,7 @@ module LlmUsage
64
64
  # and caching would freeze the staleness note. Only the API call caches.
65
65
  data, note = fetch_claude_usage(period: label, cache: cache, now: now)
66
66
  notes << note unless note.nil? || note.empty?
67
- rows.concat(parse_claude(data, now: now)) if data
67
+ rows.concat(parse_claude(data, now: now, period: label)) if data
68
68
  end
69
69
 
70
70
  if wanted.include?(:codex)
@@ -294,7 +294,7 @@ module LlmUsage
294
294
  "#{mins}m"
295
295
  end
296
296
 
297
- def parse_claude(data, now: Time.now)
297
+ def parse_claude(data, now: Time.now, period: nil)
298
298
  return [] unless data.is_a?(Hash)
299
299
 
300
300
  five = data['five_hour']
@@ -329,13 +329,13 @@ module LlmUsage
329
329
  month_reset: '-'
330
330
  )
331
331
  end
332
- return rows
332
+ return rows + claude_scoped_rows(data['limits'], rows, now: now, period: period)
333
333
  end
334
334
 
335
335
  week = data['seven_day']
336
336
  return [] unless five.is_a?(Hash) || week.is_a?(Hash)
337
337
 
338
- [
338
+ rows = [
339
339
  UsageRow.new(
340
340
  name: 'Claude',
341
341
  session_pct: format_pct(session_pct),
@@ -346,6 +346,36 @@ module LlmUsage
346
346
  month_reset: month[:reset]
347
347
  )
348
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
349
379
  end
350
380
 
351
381
  def parse_codex(data, now: Time.now)
@@ -403,7 +433,7 @@ module LlmUsage
403
433
  return fetch_cached(:claude, cache) { fetch_claude_oauth_usage } if period == 'month'
404
434
 
405
435
  data, note = fetch_claude_snapshot(now: now, path: path)
406
- return [data, note] if data
436
+ return [merge_scoped_limits(data, cache: cache), note] if data
407
437
 
408
438
  api_data, api_note = fetch_cached(:claude, cache) { fetch_claude_oauth_usage }
409
439
  return [api_data, api_note] if api_data
@@ -411,6 +441,18 @@ module LlmUsage
411
441
  [nil, [note, api_note].compact.join('; ')]
412
442
  end
413
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
+
414
456
  def fetch_claude_snapshot(now: Time.now, path: CLAUDE_LIMITS_PATH)
415
457
  snapshot = read_json(path)
416
458
  return [nil, 'claude: no local snapshot (statusline writer not installed)'] unless snapshot.is_a?(Hash)
@@ -685,11 +727,12 @@ module LlmUsage
685
727
  end
686
728
  end
687
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.
688
733
  data, note = yield
689
- if data
690
- FileUtils.mkdir_p(CACHE_DIR)
691
- File.write(path, JSON.generate('fetched_at' => Time.now.iso8601, 'data' => data, 'note' => note))
692
- end
734
+ FileUtils.mkdir_p(CACHE_DIR)
735
+ File.write(path, JSON.generate('fetched_at' => Time.now.iso8601, 'data' => data, 'note' => note))
693
736
  [data, note]
694
737
  end
695
738
 
@@ -673,10 +673,12 @@ module LlmWrap
673
673
  # * a sequence, leaving the introducer stranded and its tail printed as
674
674
  # text - the literal "[22m" on screen
675
675
  # * a UTF-8 character, which comes out as a replacement glyph
676
- # * a DECSC the child opened and has not closed yet. The terminal has one
677
- # save slot; we take it, and the child's own ESC8 then puts its cursor on
678
- # our bar and it draws the rest of the frame over the top. Claude Code
679
- # wraps every repaint in ESC7 ... ESC8, so this one is not theoretical.
676
+ # * a cursor save the child opened and has not restored yet, in either the
677
+ # DECSC (ESC7/ESC8) or SCO (CSI s / CSI u) form. The terminal has one save
678
+ # slot; we take it, and the child's own restore then puts its cursor on our
679
+ # bar and it draws the rest of the frame over the top. Claude Code brackets
680
+ # every repaint in ESC7 ... ESC8 and OpenCode uses CSI s ... CSI u, so this
681
+ # one is not theoretical.
680
682
  #
681
683
  # This is only ever asked about the *end* of what we have written so far, so
682
684
  # there is no need to understand the sequences - just to know where they stop.
@@ -774,11 +776,20 @@ module LlmWrap
774
776
  end
775
777
 
776
778
  def csi(byte)
779
+ first = @len.zero?
777
780
  @len += 1
778
781
  return @state = :esc if byte == ESC # aborted, a new one starting
779
782
  return @state = :text if @len > MAX_CSI
780
783
 
781
- @state = :text if byte >= 0x40 && byte <= 0x7e
784
+ if byte >= 0x40 && byte <= 0x7e
785
+ # SCO save/restore (CSI s / CSI u) share the single save slot with
786
+ # DECSC/DECRC on the emulators we run against, and OpenCode repaints
787
+ # through this pair - a paint between them takes the slot and its
788
+ # restore lands the rest of the frame on our bar.
789
+ @saved += 1 if first && byte == 0x73
790
+ @saved -= 1 if first && byte == 0x75 && @saved.positive?
791
+ @state = :text
792
+ end
782
793
  end
783
794
 
784
795
  # String payloads run to BEL or ST (ESC \).
data/recipes/llm.rb CHANGED
@@ -9,7 +9,7 @@ desc <<~TXT
9
9
  browser drive a local Chrome with Stagehand, thinking through claude / codex / ollama
10
10
  memory persistent memory store (backs the Claude Code memory plugin)
11
11
  plan apply a /plan bundle - sha1 checked, drift aware
12
- prompt token-prefix prompt expander (UserPromptSubmit hook + CLI)
12
+ prompt ::token prompt expander (UserPromptSubmit hook + CLI)
13
13
 
14
14
  Commands:
15
15
  usage subscription limits and per-model token usage
@@ -45,8 +45,10 @@ task :usage do
45
45
  `~/.grok/logs/unified.jsonl` for the period end; tokens still sum
46
46
  inference_done events in that log. Claude 5h/week windows come from the
47
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).
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).
50
52
  D
51
53
  example 'usage'
52
54
  example 'usage month'
@@ -205,6 +207,11 @@ namespace :wrap do
205
207
  approvals-and-sandbox, --always-approve, --auto
206
208
  -b bare - no AGENTS.md injection skips the default below
207
209
 
210
+ --update runs each CLI's own updater (claude/codex/grok update, opencode upgrade),
211
+ reports old -> new, then re-reads the model lists and shows what appeared or
212
+ disappeared since the last run. A TOOL prefix scopes it to that one tool; no prefix
213
+ updates all four.
214
+
208
215
  By default every AGENTS.md from ~ down to cwd is handed to the tool: claude
209
216
  --append-system-prompt, codex -c developer_instructions, grok --rules, opencode
210
217
  inline config. codex, grok and opencode read AGENTS.md from the git root down by
@@ -215,10 +222,12 @@ namespace :wrap do
215
222
  example 'wrap:run g -cf'
216
223
  example 'wrap:run cod -cb'
217
224
  example 'wrap:run c -- --model opus'
225
+ example 'wrap:run --update'
218
226
 
219
227
  opt :continue, type: :boolean, desc: 'continue the last session in this folder'
220
228
  opt :full, type: :boolean, desc: 'full permissions, no approval prompts'
221
229
  opt :bare, type: :boolean, desc: 'bare agent - do not inject AGENTS.md'
230
+ opt :update, type: :boolean, desc: 'update the agent CLIs, then show model changes'
222
231
 
223
232
  proc do |opts|
224
233
  require File.join(_llm_root, 'lib/llm/wrap')
@@ -230,6 +239,11 @@ namespace :wrap do
230
239
  error("unknown tool #{prefix.inspect} - one of #{LlmLaunch::TOOLS.keys.join(', ')}")
231
240
  extra = prefix ? args.drop(1) : args
232
241
 
242
+ if opts[:update]
243
+ require File.join(_llm_root, 'lib/llm/update')
244
+ exit LlmUpdate.run(prefix ? [tool] : LlmLaunch::TOOLS.keys)
245
+ end
246
+
233
247
  launch = LlmLaunch.build(tool, extra, continue: opts[:continue], full: opts[:full], bare: opts[:bare])
234
248
  ENV.update(launch.env)
235
249
  say.gray "agents: #{launch.files.map { |f| f.to_s.sub(Dir.home, '~') }.join(', ')}" if launch.files.any?
@@ -484,7 +498,7 @@ end
484
498
 
485
499
  namespace :prompt do
486
500
  TOKEN_PATTERN ||= /[a-z0-9_-]+/.freeze
487
- TOKEN_LINE_RE ||= /\A(?:\s*:[a-z0-9_-]+)+\s*\z/.freeze
501
+ TOKEN_LINE_RE ||= /\A(?:\s*::[a-z0-9_-]+)+\s*\z/.freeze
488
502
  HOOK_EVENT ||= 'UserPromptSubmit'
489
503
  VERBATIM_INSTRUCTION ||= "INSTRUCTION TO ASSISTANT: Do not answer the user's prompt. Print the message below verbatim to the user, preserving every line exactly as written. Do not summarize, truncate, or paraphrase."
490
504
  QUESTION_RULE ||= <<~RULE.strip
@@ -506,8 +520,8 @@ namespace :prompt do
506
520
  def tokens_in(input)
507
521
  out = []
508
522
  scanner = input.to_s.strip
509
- while (m = scanner.match(/\A(?::(?<pre>#{TOKEN_PATTERN})|(?<post>#{TOKEN_PATTERN}):)(?=\s|$)/))
510
- out << (m[:pre] || m[:post])
523
+ while (m = scanner.match(/\A::(#{TOKEN_PATTERN})(?=\s|$)/))
524
+ out << m[1]
511
525
  scanner = scanner[m[0].length..].to_s.lstrip
512
526
  end
513
527
  out.uniq
@@ -529,7 +543,7 @@ namespace :prompt do
529
543
  ordered = folders.sort_by { |label, _| label == 'global' ? 0 : 1 }
530
544
  ordered.map do |label, folder|
531
545
  toks = Dir.glob(File.join(folder, '*.md')).map { |p| File.basename(p, '.md') }.sort
532
- items = toks.empty? ? '(none)' : toks.map { |t| ":#{t}" }.join(', ')
546
+ items = toks.empty? ? '(none)' : toks.map { |t| "::#{t}" }.join(', ')
533
547
  "Available #{label} in #{display_path(folder)} -> #{items}"
534
548
  end.join("\n")
535
549
  end
@@ -547,7 +561,7 @@ namespace :prompt do
547
561
  name = File.basename(path, '.md')
548
562
  dscr = first_line_description(path)
549
563
  dscr = '(no description)' if dscr.empty?
550
- " :#{name.ljust(width)} #{dscr}"
564
+ " ::#{name.ljust(width)} #{dscr}"
551
565
  end
552
566
  "#{header}\n#{entries.join("\n")}"
553
567
  end
@@ -617,7 +631,7 @@ namespace :prompt do
617
631
  end
618
632
 
619
633
  break unless stripped =~ TOKEN_LINE_RE
620
- prefix_tokens.concat(stripped.scan(/:(#{TOKEN_PATTERN})/).flatten)
634
+ prefix_tokens.concat(stripped.scan(/::(#{TOKEN_PATTERN})/).flatten)
621
635
  remaining = rest.to_s
622
636
  end
623
637
 
@@ -638,9 +652,9 @@ namespace :prompt do
638
652
  end
639
653
 
640
654
  def load_command_content(token, seen)
641
- error "circular include of :#{token}" if seen.include?(token)
655
+ error "circular include of ::#{token}" if seen.include?(token)
642
656
  path = find_command_path(token)
643
- error %(custom token ":#{token}" not found.\n\n#{grouped_listing}) unless path
657
+ error %(custom token "::#{token}" not found.\n\n#{grouped_listing}) unless path
644
658
 
645
659
  child_seen = seen + [token]
646
660
  content = File.read(path)
@@ -669,7 +683,7 @@ namespace :prompt do
669
683
  seen = Set.new
670
684
  loaded = toks.map do |token|
671
685
  path = find_command_path(token)
672
- error %(custom token ":#{token}" not found.\n\n#{grouped_listing}) unless path
686
+ error %(custom token "::#{token}" not found.\n\n#{grouped_listing}) unless path
673
687
  [token, Pathname.new(path).cleanpath.to_s, load_command_content(token, seen)]
674
688
  end
675
689
 
@@ -719,12 +733,11 @@ namespace :prompt do
719
733
 
720
734
  task :expand do
721
735
  desc 'Expand prompt token(s) and print the resulting context'
722
- example 'prompt:expand :foo :bar'
723
- example 'prompt:expand foo:'
736
+ example 'prompt:expand ::foo ::bar'
724
737
 
725
738
  proc do |opts|
726
739
  input = opts[:args].join(' ')
727
- error 'usage: llm prompt:expand :token [:token ...]' if input.empty?
740
+ error 'usage: llm prompt:expand ::token [::token ...]' if input.empty?
728
741
  out = load_context(input)
729
742
  say out unless out.empty?
730
743
  end
@@ -733,16 +746,17 @@ namespace :prompt do
733
746
  task :hook do
734
747
  desc <<~D
735
748
  UserPromptSubmit hook entry. Reads {"prompt": ...} JSON on stdin,
736
- expands any token prefix, and emits hookSpecificOutput JSON on stdout.
749
+ expands any ::token prefix, and emits hookSpecificOutput JSON on stdout.
737
750
 
738
751
  Pair with HAMMER_QUIET=1 in the hook command so the runtime banner
739
752
  doesn't pollute stdout.
740
753
  D
741
754
  opt :claude, type: :boolean, default: false, desc: 'Claude Code hook mode'
742
755
  opt :codex, type: :boolean, default: false, desc: 'Codex hook mode'
756
+ opt :grok, type: :boolean, default: false, desc: 'Grok CLI hook mode'
743
757
 
744
758
  proc do |opts|
745
- error '--claude or --codex required' unless opts[:claude] || opts[:codex]
759
+ error '--claude, --codex or --grok required' unless opts[:claude] || opts[:codex] || opts[:grok]
746
760
 
747
761
  raw = opts[:stdin].to_s
748
762
  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.28
4
+ version: 0.3.30
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-09 00:00:00.000000000 Z
11
+ date: 2026-09-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: minitest
@@ -58,6 +58,7 @@ files:
58
58
  - "./recipes/lib/llm/browser.rb"
59
59
  - "./recipes/lib/llm/launch.rb"
60
60
  - "./recipes/lib/llm/plan.rb"
61
+ - "./recipes/lib/llm/update.rb"
61
62
  - "./recipes/lib/llm/usage.rb"
62
63
  - "./recipes/lib/llm/wrap.rb"
63
64
  - "./recipes/llm.rb"