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 +4 -4
- data/.version +1 -1
- data/AGENTS.md +21 -2
- data/lib/hammer/version.rb +52 -0
- data/lib/lux-hammer.rb +8 -4
- data/recipes/git-helper.rb +20 -0
- data/recipes/lib/llm/launch.rb +7 -0
- data/recipes/lib/llm/update.rb +140 -0
- data/recipes/lib/llm/usage.rb +9 -2
- data/recipes/lib/llm/wrap.rb +16 -5
- data/recipes/llm.rb +25 -14
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 62f3507ae302513e173b9c4ee719b1c27448d47ef4a8568f0be0e8e8485410c5
|
|
4
|
+
data.tar.gz: 92983a6f6e7c0dd96c75f0dd3abca3bab91808c4f2c10b93205aa426932d7405
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1fd6204a4915f166d68bbb51fe33474ae44dff46e088486e939b0f7b887378a150548cb5fb646c25699381fcfeb79c97ed4028ee0149a1f8872daac38b755eee
|
|
7
|
+
data.tar.gz: c798f9716fe791388075077034486e817dca3025a9f042bf04d8ac472c2af8e1c67d36b4914c8c100ecf7fd253d830738ccca619ce2aabe7808130232c980571
|
data/.version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
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::
|
|
19
|
-
`Hammer::
|
|
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
|
|
51
|
-
|
|
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
|
data/recipes/git-helper.rb
CHANGED
|
@@ -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
|
data/recipes/lib/llm/launch.rb
CHANGED
|
@@ -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
|
data/recipes/lib/llm/usage.rb
CHANGED
|
@@ -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
|
|
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.
|
data/recipes/lib/llm/wrap.rb
CHANGED
|
@@ -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
|
|
677
|
-
#
|
|
678
|
-
#
|
|
679
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
512
|
-
out <<
|
|
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| "
|
|
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
|
-
"
|
|
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(
|
|
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
|
|
655
|
+
error "circular include of ::#{token}" if seen.include?(token)
|
|
644
656
|
path = find_command_path(token)
|
|
645
|
-
error %(custom token "
|
|
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 "
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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"
|