lux-hammer 0.3.29 → 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: 6dab0deb8eba80ec1aac72b16620370af2d80d2528641e9117be71df84e8d416
4
- data.tar.gz: 6460bbda170ba18cedf234846e4898f1c6efc48732c81ed122fc6eaa11d237b8
3
+ metadata.gz: 2ca175d7445c386d0c8bd3be6834b057e6b958ee3d0de48d72e49a53ef4da776
4
+ data.tar.gz: 3303a15ee74702d81ebfba70d15366870bfb8dccc2d5b0fc8c5304e2fe28ef0e
5
5
  SHA512:
6
- metadata.gz: c24c74422db1ac9a72b5a03ae4f7d4e56e486c7622be40f5e136c16b9a15b2a04feeb7d2f61cdfdaaeafbc5c3913e71c2f8faf567cac8ea236eaeeadcb7fcb4f
7
- data.tar.gz: 823288d67fd112a118c09b3b1dd3599a3b080c5c74fd6e5a35492be182a72fa3fbe82359498e1facb0396b61773b1a51e61dba786284d2bde19426a8e3eddc81
6
+ metadata.gz: d925611cf831aa0e6c4198d4761bdcb04b7454afab82a8cc12100130e23dc63139f3bad8fbf0473cd67f7bf71ea46ef3ceeb91e266f6bddba51f4097469231dc
7
+ data.tar.gz: a489e910f4027422474b1741a0ff45e4e5729009af2d5c6ba7fbf79a7f11661e547b9947058c6b5c237e41fbecfc5baa9a2a2686158a95460a0c2be7ba4bf329
data/.version CHANGED
@@ -1 +1 @@
1
- 0.3.29
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
@@ -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
@@ -207,6 +207,11 @@ namespace :wrap do
207
207
  approvals-and-sandbox, --always-approve, --auto
208
208
  -b bare - no AGENTS.md injection skips the default below
209
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
+
210
215
  By default every AGENTS.md from ~ down to cwd is handed to the tool: claude
211
216
  --append-system-prompt, codex -c developer_instructions, grok --rules, opencode
212
217
  inline config. codex, grok and opencode read AGENTS.md from the git root down by
@@ -217,10 +222,12 @@ namespace :wrap do
217
222
  example 'wrap:run g -cf'
218
223
  example 'wrap:run cod -cb'
219
224
  example 'wrap:run c -- --model opus'
225
+ example 'wrap:run --update'
220
226
 
221
227
  opt :continue, type: :boolean, desc: 'continue the last session in this folder'
222
228
  opt :full, type: :boolean, desc: 'full permissions, no approval prompts'
223
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'
224
231
 
225
232
  proc do |opts|
226
233
  require File.join(_llm_root, 'lib/llm/wrap')
@@ -232,6 +239,11 @@ namespace :wrap do
232
239
  error("unknown tool #{prefix.inspect} - one of #{LlmLaunch::TOOLS.keys.join(', ')}")
233
240
  extra = prefix ? args.drop(1) : args
234
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
+
235
247
  launch = LlmLaunch.build(tool, extra, continue: opts[:continue], full: opts[:full], bare: opts[:bare])
236
248
  ENV.update(launch.env)
237
249
  say.gray "agents: #{launch.files.map { |f| f.to_s.sub(Dir.home, '~') }.join(', ')}" if launch.files.any?
@@ -486,7 +498,7 @@ end
486
498
 
487
499
  namespace :prompt do
488
500
  TOKEN_PATTERN ||= /[a-z0-9_-]+/.freeze
489
- TOKEN_LINE_RE ||= /\A(?:\s*:[a-z0-9_-]+)+\s*\z/.freeze
501
+ TOKEN_LINE_RE ||= /\A(?:\s*::[a-z0-9_-]+)+\s*\z/.freeze
490
502
  HOOK_EVENT ||= 'UserPromptSubmit'
491
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."
492
504
  QUESTION_RULE ||= <<~RULE.strip
@@ -508,8 +520,8 @@ namespace :prompt do
508
520
  def tokens_in(input)
509
521
  out = []
510
522
  scanner = input.to_s.strip
511
- while (m = scanner.match(/\A(?::(?<pre>#{TOKEN_PATTERN})|(?<post>#{TOKEN_PATTERN}):)(?=\s|$)/))
512
- out << (m[:pre] || m[:post])
523
+ while (m = scanner.match(/\A::(#{TOKEN_PATTERN})(?=\s|$)/))
524
+ out << m[1]
513
525
  scanner = scanner[m[0].length..].to_s.lstrip
514
526
  end
515
527
  out.uniq
@@ -531,7 +543,7 @@ namespace :prompt do
531
543
  ordered = folders.sort_by { |label, _| label == 'global' ? 0 : 1 }
532
544
  ordered.map do |label, folder|
533
545
  toks = Dir.glob(File.join(folder, '*.md')).map { |p| File.basename(p, '.md') }.sort
534
- items = toks.empty? ? '(none)' : toks.map { |t| ":#{t}" }.join(', ')
546
+ items = toks.empty? ? '(none)' : toks.map { |t| "::#{t}" }.join(', ')
535
547
  "Available #{label} in #{display_path(folder)} -> #{items}"
536
548
  end.join("\n")
537
549
  end
@@ -549,7 +561,7 @@ namespace :prompt do
549
561
  name = File.basename(path, '.md')
550
562
  dscr = first_line_description(path)
551
563
  dscr = '(no description)' if dscr.empty?
552
- " :#{name.ljust(width)} #{dscr}"
564
+ " ::#{name.ljust(width)} #{dscr}"
553
565
  end
554
566
  "#{header}\n#{entries.join("\n")}"
555
567
  end
@@ -619,7 +631,7 @@ namespace :prompt do
619
631
  end
620
632
 
621
633
  break unless stripped =~ TOKEN_LINE_RE
622
- prefix_tokens.concat(stripped.scan(/:(#{TOKEN_PATTERN})/).flatten)
634
+ prefix_tokens.concat(stripped.scan(/::(#{TOKEN_PATTERN})/).flatten)
623
635
  remaining = rest.to_s
624
636
  end
625
637
 
@@ -640,9 +652,9 @@ namespace :prompt do
640
652
  end
641
653
 
642
654
  def load_command_content(token, seen)
643
- error "circular include of :#{token}" if seen.include?(token)
655
+ error "circular include of ::#{token}" if seen.include?(token)
644
656
  path = find_command_path(token)
645
- 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
646
658
 
647
659
  child_seen = seen + [token]
648
660
  content = File.read(path)
@@ -671,7 +683,7 @@ namespace :prompt do
671
683
  seen = Set.new
672
684
  loaded = toks.map do |token|
673
685
  path = find_command_path(token)
674
- 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
675
687
  [token, Pathname.new(path).cleanpath.to_s, load_command_content(token, seen)]
676
688
  end
677
689
 
@@ -721,12 +733,11 @@ namespace :prompt do
721
733
 
722
734
  task :expand do
723
735
  desc 'Expand prompt token(s) and print the resulting context'
724
- example 'prompt:expand :foo :bar'
725
- example 'prompt:expand foo:'
736
+ example 'prompt:expand ::foo ::bar'
726
737
 
727
738
  proc do |opts|
728
739
  input = opts[:args].join(' ')
729
- error 'usage: llm prompt:expand :token [:token ...]' if input.empty?
740
+ error 'usage: llm prompt:expand ::token [::token ...]' if input.empty?
730
741
  out = load_context(input)
731
742
  say out unless out.empty?
732
743
  end
@@ -735,7 +746,7 @@ namespace :prompt do
735
746
  task :hook do
736
747
  desc <<~D
737
748
  UserPromptSubmit hook entry. Reads {"prompt": ...} JSON on stdin,
738
- expands any token prefix, and emits hookSpecificOutput JSON on stdout.
749
+ expands any ::token prefix, and emits hookSpecificOutput JSON on stdout.
739
750
 
740
751
  Pair with HAMMER_QUIET=1 in the hook command so the runtime banner
741
752
  doesn't pollute stdout.
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.29
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-10 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"