lux-hammer 0.3.29 → 0.8.4

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: 62f3507ae302513e173b9c4ee719b1c27448d47ef4a8568f0be0e8e8485410c5
4
+ data.tar.gz: 92983a6f6e7c0dd96c75f0dd3abca3bab91808c4f2c10b93205aa426932d7405
5
5
  SHA512:
6
- metadata.gz: c24c74422db1ac9a72b5a03ae4f7d4e56e486c7622be40f5e136c16b9a15b2a04feeb7d2f61cdfdaaeafbc5c3913e71c2f8faf567cac8ea236eaeeadcb7fcb4f
7
- data.tar.gz: 823288d67fd112a118c09b3b1dd3599a3b080c5c74fd6e5a35492be182a72fa3fbe82359498e1facb0396b61773b1a51e61dba786284d2bde19426a8e3eddc81
6
+ metadata.gz: 1fd6204a4915f166d68bbb51fe33474ae44dff46e088486e939b0f7b887378a150548cb5fb646c25699381fcfeb79c97ed4028ee0149a1f8872daac38b755eee
7
+ data.tar.gz: c798f9716fe791388075077034486e817dca3025a9f042bf04d8ac472c2af8e1c67d36b4914c8c100ecf7fd253d830738ccca619ce2aabe7808130232c980571
data/.version CHANGED
@@ -1 +1 @@
1
- 0.3.29
1
+ 0.8.4
data/AGENTS.md CHANGED
@@ -15,8 +15,9 @@ usable as a library (`require 'lux-hammer'`, subclass `Hammer`, call
15
15
 
16
16
  * **One root constant**: `Hammer`. Never pollute `Object` or introduce
17
17
  another top-level constant. Sub-types live as `Hammer::Shell`,
18
- `Hammer::Option`, `Hammer::Parser`, `Hammer::Command`,
19
- `Hammer::Loader`, `Hammer::Builder`, `Hammer::CommandBuilder`. The one
18
+ `Hammer::Version`, `Hammer::Option`, `Hammer::Parser`,
19
+ `Hammer::Command`, `Hammer::Loader`, `Hammer::Builder`,
20
+ `Hammer::CommandBuilder`. The one
20
21
  exception is `String#color(:name)` (defined in `lib/hammer/shell.rb`)
21
22
  - a tiny convenience for `"hi".color(:cyan)`. Do not add more
22
23
  monkey-patches.
@@ -39,6 +40,7 @@ usable as a library (`require 'lux-hammer'`, subclass `Hammer`, call
39
40
  bin/hammer # CLI entry point
40
41
  lib/lux-hammer.rb # Entry - defines class Hammer and its DSL
41
42
  lib/hammer/shell.rb # ANSI/IO helpers
43
+ lib/hammer/version.rb # Hammer::Version - git-count version + .version fallback
42
44
  lib/hammer/option.rb # One option definition (types: string/boolean/integer/float/array/json)
43
45
  lib/hammer/input.rb # opts[:stdin], prepare_json!, JSON @file / pipe helpers
44
46
  lib/hammer/parser.rb # ARGV -> [positional, opts_hash]
@@ -58,6 +60,7 @@ test/parser_test.rb # ARGV parsing edge cases
58
60
  test/option_test.rb # Option declaration / casting
59
61
  test/command_test.rb # Command data type
60
62
  test/shell_test.rb # ANSI / IO helpers
63
+ test/version_test.rb # version formatting (git count -> dotted)
61
64
  test/cli_test.rb # `hammer` binary end-to-end
62
65
  test/cron_test.rb # schedule parser (matches?/due?/next_run)
63
66
  test/cron_server_test.rb # job discovery, state, rotation, web UI
@@ -422,6 +425,22 @@ new code in this gem should show.
422
425
  * Inside a handler, just call `error 'msg'` - it resolves via the
423
426
  `Shell` mixin.
424
427
 
428
+ ## Versioning
429
+
430
+ * `Hammer::VERSION` is the dboss-style formatted commit count on `main`
431
+ (falling back to `master`, then `HEAD`): `v82` -> `0.8.2`, `v1123` ->
432
+ `11.2.3`, left-padded so `v5` -> `0.0.5`. It is bare (no leading `v`)
433
+ so it stays a valid `Gem::Version`.
434
+ * `Hammer::Version.string` is that value, `Hammer::Version.raw` is the
435
+ `v<count>` behind it, and `Hammer::Version.format` is the pure
436
+ formatter (a non-`v<digits>` string is returned unchanged).
437
+ * A released gem has no `.git` and falls back to the committed
438
+ `.version` file. `hammer gem --inc` writes the git-count value into
439
+ `.version` (overwriting `.version.prev`), so the built gem's version
440
+ matches the checkout; the release workflow tags `v$(cat .version)`.
441
+ * Don't reintroduce manual major/minor/patch bumps - the count is the
442
+ version.
443
+
425
444
  ## Coding rules
426
445
 
427
446
  * No trailing spaces on empty lines. End every file with a newline.
@@ -0,0 +1,52 @@
1
+ class Hammer
2
+ # The running version, derived the way dboss derives its own: the number
3
+ # of commits reachable from main (or master, or HEAD) rendered as a dotted
4
+ # triple, so v82 -> 0.8.2 and v1123 -> 11.2.3. A released gem has no .git
5
+ # and falls back to the .version file baked in at build time, which is also
6
+ # what `hammer gem --inc` writes.
7
+ module Version
8
+ module_function
9
+
10
+ ROOT ||= File.expand_path('../..', __dir__)
11
+
12
+ # Dotted, no leading v, so it stays a valid Gem::Version (v1.2.3 -> 1.2.3).
13
+ def string
14
+ format(raw)
15
+ end
16
+
17
+ # v<commit count> in a git checkout, else the packaged .version string.
18
+ def raw
19
+ count = git_count
20
+ count ? "v#{count}" : file_version
21
+ end
22
+
23
+ # v123 -> 1.2.3, v1123 -> 11.2.3, v5 -> 0.0.5. Anything that is not
24
+ # v<digits> (dev, an already-dotted version) is returned unchanged.
25
+ def format(version)
26
+ text = version.to_s
27
+ count = text.start_with?('v') ? text[1..] : nil
28
+ return text unless count && !count.empty? && count.match?(/\A\d+\z/)
29
+
30
+ count = count.rjust(3, '0')
31
+ "#{count[0...-2]}.#{count[-2]}.#{count[-1]}"
32
+ end
33
+
34
+ def git_count
35
+ return nil unless File.exist?(File.join(ROOT, '.git'))
36
+
37
+ %w[main master HEAD].each do |ref|
38
+ out = IO.popen(['git', '-C', ROOT, 'rev-list', '--count', ref],
39
+ err: File::NULL, &:read).to_s.strip
40
+ return out.to_i if out.match?(/\A\d+\z/)
41
+ end
42
+ nil
43
+ rescue SystemCallError
44
+ nil
45
+ end
46
+
47
+ def file_version
48
+ path = File.join(ROOT, '.version')
49
+ File.exist?(path) ? File.read(path).strip : 'dev'
50
+ end
51
+ end
52
+ end
data/lib/lux-hammer.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  require_relative 'hammer/shell'
2
+ require_relative 'hammer/version'
2
3
  require_relative 'hammer/input'
3
4
  require_relative 'hammer/option'
4
5
  require_relative 'hammer/parser'
@@ -47,8 +48,9 @@ class Hammer
47
48
  Error ||= Class.new(StandardError)
48
49
  AmbiguousMatch ||= Class.new(Error)
49
50
 
50
- # Gem version, read once from the bundled .version file.
51
- VERSION ||= File.read(File.expand_path('../.version', __dir__)).strip
51
+ # Gem version: the formatted main-branch commit count in a checkout, or the
52
+ # bundled .version baked into a released gem. See Hammer::Version.
53
+ VERSION ||= Version.string
52
54
 
53
55
  # Convenience aliases so recipes can write `Hammer.prepare_json!(opts)`
54
56
  # without the Input module path. See Hammer::Input.
@@ -1281,10 +1283,12 @@ class Hammer
1281
1283
  # built-ins (so the script's `--help` shows only what it defines).
1282
1284
  # `program_name` is the script's basename so help reads "myscript foo"
1283
1285
  # rather than "hammer foo" - works even when invoked via a symlink in
1284
- # PATH, since argv[0] is the path the user typed.
1286
+ # PATH, since argv[0] is the path the user typed. `.rb` is stripped so
1287
+ # a recipe exec'd as `recipes/llm.rb` (typical `~/bin/llm` stub) still
1288
+ # banners as `llm`, matching `Hammer.recipe(:llm)`.
1285
1289
  def self.run_shebang(path, argv)
1286
1290
  klass = Class.new(Hammer)
1287
- klass.instance_variable_set(:@program_name, File.basename(path))
1291
+ klass.instance_variable_set(:@program_name, File.basename(path, '.rb'))
1288
1292
  Builder.new(klass).evaluate(File.read(path), path)
1289
1293
  klass.start(argv)
1290
1294
  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
@@ -770,12 +770,19 @@ module LlmUsage
770
770
 
771
771
  def row_cells(row, period)
772
772
  if period_label(period) == 'month'
773
- [row.name, row.month_pct || '-', row.month_reset || '-']
773
+ [row.name, utilization_bar(row.month_pct), row.month_reset || '-']
774
774
  else
775
- [row.name, row.session_pct, row.session_reset, row.week_pct, row.week_reset]
775
+ [row.name, utilization_bar(row.session_pct), row.session_reset, utilization_bar(row.week_pct), row.week_reset]
776
776
  end
777
777
  end
778
778
 
779
+ def utilization_bar(value)
780
+ return '-' if value.nil? || value == '-'
781
+
782
+ filled = (value.to_f.clamp(0, 100) / 10).round
783
+ "[#{'#' * filled}#{'-' * (10 - filled)}] #{value.rjust(4)}"
784
+ end
785
+
779
786
  def row_to_hash(row, period: nil)
780
787
  if period_label(period) == 'month'
781
788
  # Mirror row_cells: providers with no month window render '-', not null.
@@ -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.8.4
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-10-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: minitest
@@ -52,12 +52,14 @@ files:
52
52
  - "./lib/hammer/parser.rb"
53
53
  - "./lib/hammer/recipe.rb"
54
54
  - "./lib/hammer/shell.rb"
55
+ - "./lib/hammer/version.rb"
55
56
  - "./lib/lux-hammer.rb"
56
57
  - "./recipes/deploy.rb"
57
58
  - "./recipes/git-helper.rb"
58
59
  - "./recipes/lib/llm/browser.rb"
59
60
  - "./recipes/lib/llm/launch.rb"
60
61
  - "./recipes/lib/llm/plan.rb"
62
+ - "./recipes/lib/llm/update.rb"
61
63
  - "./recipes/lib/llm/usage.rb"
62
64
  - "./recipes/lib/llm/wrap.rb"
63
65
  - "./recipes/llm.rb"