lux-hammer 0.3.20 → 0.3.22
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/lib/hammer/shell.rb +33 -12
- data/recipes/deploy.rb +15 -7
- data/recipes/lib/llm/plan.rb +651 -0
- data/recipes/lib/llm/wrap.rb +258 -6
- data/recipes/llm.rb +153 -18
- metadata +3 -11
- data/recipes/lib/deploy/boot.rb +0 -52
- data/recipes/lib/deploy/commands.rb +0 -555
- data/recipes/lib/deploy/config.rb +0 -62
- data/recipes/lib/deploy/context.rb +0 -149
- data/recipes/lib/deploy/doctor.rb +0 -238
- data/recipes/lib/deploy/hammer.rb +0 -168
- data/recipes/lib/deploy/manifest.rb +0 -169
- data/recipes/lib/deploy/ssh.rb +0 -129
- data/recipes/lib/deploy/template.rb +0 -39
data/recipes/lib/llm/wrap.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'fileutils'
|
|
4
4
|
require 'io/console'
|
|
5
5
|
require 'pty'
|
|
6
|
+
require 'shellwords'
|
|
6
7
|
|
|
7
8
|
# Run a command in a PTY with the last prompts you typed pinned to the bottom
|
|
8
9
|
# rows of the screen, under a "prompt history" rule.
|
|
@@ -36,6 +37,20 @@ module LlmWrap
|
|
|
36
37
|
MAX_DEFER ||= 0.15
|
|
37
38
|
CHUNK ||= 65_536
|
|
38
39
|
|
|
40
|
+
# A pty master hands back at most a kilobyte per read whatever CHUNK says, so
|
|
41
|
+
# one redraw arrives as dozens of pieces. DRAIN_MAX caps how much of it we
|
|
42
|
+
# take in a single go, so a child that never stops talking cannot starve the
|
|
43
|
+
# keyboard. MAX_BLOCK is how long a paint will wait for a safe moment before
|
|
44
|
+
# giving up and taking one - see Session#paint.
|
|
45
|
+
DRAIN_MAX ||= 262_144
|
|
46
|
+
MAX_BLOCK ||= 1.5
|
|
47
|
+
|
|
48
|
+
# Introducers of the escape sequences that carry a string payload:
|
|
49
|
+
# OSC ], DCS P, SOS X, PM ^, APC _. They run to BEL or ST rather than to a
|
|
50
|
+
# final byte, so both stream parsers here - KeyBuffer on the way in, OutScan
|
|
51
|
+
# on the way out - have to know them to find the end of one.
|
|
52
|
+
STRING_INTRO ||= [0x5d, 0x50, 0x58, 0x5e, 0x5f].freeze
|
|
53
|
+
|
|
39
54
|
# `origin` is how this wrapper was asked for, in the words that would ask for
|
|
40
55
|
# it again - only the caller knows that, since taking the child's name throws
|
|
41
56
|
# our own command line away. It is what Handoff writes down; the child's argv
|
|
@@ -137,15 +152,78 @@ module LlmWrap
|
|
|
137
152
|
end
|
|
138
153
|
end
|
|
139
154
|
|
|
155
|
+
# The commands `llm wrap` offers when asked for no particular one. Plain
|
|
156
|
+
# text, one command per line - nothing to learn, editable with anything.
|
|
157
|
+
#
|
|
158
|
+
# A # comment is dropped, so a command can be parked without being deleted.
|
|
159
|
+
# A line with no letter in it is kept but not runnable: blanks and ---- rules
|
|
160
|
+
# stay visible in the picker to group the list, the cursor just steps over
|
|
161
|
+
# them. No executable name is spelled without a letter, so nothing real is
|
|
162
|
+
# caught by that.
|
|
163
|
+
module Config
|
|
164
|
+
DEFAULTS ||= [
|
|
165
|
+
'claude --dangerously-skip-permissions --continue',
|
|
166
|
+
'codex resume --last --dangerously-bypass-approvals-and-sandbox',
|
|
167
|
+
'grok --always-approve --continue'
|
|
168
|
+
].freeze
|
|
169
|
+
|
|
170
|
+
# A method rather than a constant for the same reason as Handoff.dir: the
|
|
171
|
+
# tests need somewhere else to write.
|
|
172
|
+
def self.path
|
|
173
|
+
ENV['LLM_WRAP_CONFIG'] || File.expand_path('~/.config/hammer/llm-wrap.txt')
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def self.lines
|
|
177
|
+
return [] unless File.file?(path)
|
|
178
|
+
|
|
179
|
+
# Decide what to keep first, then tidy what survived - lstrip inside the
|
|
180
|
+
# test so an indented # is still a comment.
|
|
181
|
+
File.readlines(path, chomp: true)
|
|
182
|
+
.reject { |line| line.lstrip.start_with?('#') }
|
|
183
|
+
.map(&:strip)
|
|
184
|
+
rescue SystemCallError, IOError
|
|
185
|
+
[]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# A row the picker shows but will not run. Nothing without a letter in it
|
|
189
|
+
# can name a program, which makes ---- and a blank line free to use as
|
|
190
|
+
# dividers without needing a syntax for them.
|
|
191
|
+
def self.runnable?(line)
|
|
192
|
+
line.match?(/[a-z]/i)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Split rather than hand the line over whole: PTY.spawn passes a
|
|
196
|
+
# one-element argv to /bin/sh, and a command out of this file should reach
|
|
197
|
+
# the wrapper the same way `llm wrap claude --foo` already does - as argv,
|
|
198
|
+
# no shell in between.
|
|
199
|
+
def self.argv(line)
|
|
200
|
+
Shellwords.split(line)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Writes DEFAULTS when there is nothing there yet - no file, or one that is
|
|
204
|
+
# only whitespace. A file holding just commented-out lines is left alone;
|
|
205
|
+
# those are someone's notes, not an empty file.
|
|
206
|
+
def self.seed
|
|
207
|
+
return false if File.file?(path) && !File.read(path).strip.empty?
|
|
208
|
+
|
|
209
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
210
|
+
File.write(path, "#{DEFAULTS.join("\n")}\n")
|
|
211
|
+
true
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
140
215
|
class Session
|
|
141
216
|
def initialize(argv, keep, origin = argv)
|
|
142
217
|
@argv = argv
|
|
143
218
|
@origin = origin
|
|
144
219
|
@bar_rows = keep + 1 # the prompts, plus the rule above them
|
|
145
220
|
@keys = KeyBuffer.new(keep)
|
|
221
|
+
@out = OutScan.new
|
|
146
222
|
@winch = false
|
|
147
223
|
@dirty = true
|
|
148
224
|
@drawn_at = 0.0
|
|
225
|
+
@blocked = nil
|
|
226
|
+
@skipped = nil
|
|
149
227
|
@started = now
|
|
150
228
|
@trace = (File.open(ENV['LLM_WRAP_DEBUG'], 'a') if ENV['LLM_WRAP_DEBUG'].to_s != '')
|
|
151
229
|
rescue SystemCallError
|
|
@@ -281,7 +359,7 @@ module LlmWrap
|
|
|
281
359
|
ready = IO.select([$stdin, @pty_out], nil, nil, IDLE_TICK)
|
|
282
360
|
|
|
283
361
|
unless ready
|
|
284
|
-
|
|
362
|
+
paint if @dirty # output settled
|
|
285
363
|
next
|
|
286
364
|
end
|
|
287
365
|
|
|
@@ -293,8 +371,9 @@ module LlmWrap
|
|
|
293
371
|
@dirty = true if taking && @keys.feed(data)
|
|
294
372
|
trace_in(data, taking)
|
|
295
373
|
else
|
|
296
|
-
data =
|
|
374
|
+
data = drain(@pty_out) or return
|
|
297
375
|
$stdout.write(data)
|
|
376
|
+
@out.feed(data)
|
|
298
377
|
trace_out(data)
|
|
299
378
|
# The child cannot address the bar rows, but it can switch to the
|
|
300
379
|
# alt screen (which starts blank) or scroll a region it set itself.
|
|
@@ -304,7 +383,7 @@ module LlmWrap
|
|
|
304
383
|
end
|
|
305
384
|
|
|
306
385
|
# Keep the bar honest while output streams without ever settling.
|
|
307
|
-
|
|
386
|
+
paint if @dirty && now - @drawn_at > MAX_DEFER
|
|
308
387
|
end
|
|
309
388
|
rescue Errno::EIO, Errno::EPIPE, EOFError
|
|
310
389
|
nil
|
|
@@ -316,6 +395,49 @@ module LlmWrap
|
|
|
316
395
|
nil
|
|
317
396
|
end
|
|
318
397
|
|
|
398
|
+
# Take everything the child has queued, not just the kilobyte a pty master
|
|
399
|
+
# gives back per read. One redraw then lands in a single write and the paint
|
|
400
|
+
# that follows it falls on a frame boundary instead of inside the frame.
|
|
401
|
+
def drain(io)
|
|
402
|
+
data = slurp(io) or return nil
|
|
403
|
+
|
|
404
|
+
while data.bytesize < DRAIN_MAX && IO.select([io], nil, nil, 0)
|
|
405
|
+
more = slurp(io) or break
|
|
406
|
+
data << more
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
data
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# Paint only where the terminal's parser is at rest - see OutScan for what
|
|
413
|
+
# goes wrong otherwise. The bar is cosmetic, so waiting is nearly always
|
|
414
|
+
# right; the exception is a child that leaves a sequence open for good (an
|
|
415
|
+
# unterminated OSC, a DECSC it never restores), where a bar frozen on the
|
|
416
|
+
# wrong prompts is worse than one glitched frame.
|
|
417
|
+
def paint
|
|
418
|
+
return released if @out.safe?
|
|
419
|
+
|
|
420
|
+
@blocked ||= now
|
|
421
|
+
|
|
422
|
+
if now - @blocked < MAX_BLOCK
|
|
423
|
+
# Once per state rather than once per tick, or the log is nothing else.
|
|
424
|
+
reason = @out.to_s
|
|
425
|
+
trace('SKIP', reason) if reason != @skipped
|
|
426
|
+
@skipped = reason
|
|
427
|
+
return
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
trace('FORCE', @out.to_s)
|
|
431
|
+
@out.reset!
|
|
432
|
+
released
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def released
|
|
436
|
+
@blocked = nil
|
|
437
|
+
@skipped = nil
|
|
438
|
+
draw_bar
|
|
439
|
+
end
|
|
440
|
+
|
|
319
441
|
# Terminals disagree wildly about how they report keys, and the encoding
|
|
320
442
|
# depends on modes the child turns on and off as it runs. LLM_WRAP_DEBUG=<file>
|
|
321
443
|
# records both sides raw so the two can be lined up. Off unless asked for -
|
|
@@ -368,6 +490,139 @@ module LlmWrap
|
|
|
368
490
|
end
|
|
369
491
|
end
|
|
370
492
|
|
|
493
|
+
# Follows the child's output on its way to the screen and answers one
|
|
494
|
+
# question: is the terminal's parser at rest right now?
|
|
495
|
+
#
|
|
496
|
+
# Painting the bar means writing our own escapes into that stream, and a pty
|
|
497
|
+
# master hands back at most a kilobyte per read - so a full redraw arrives as
|
|
498
|
+
# dozens of pieces split at arbitrary bytes, and a paint dropped into one of
|
|
499
|
+
# the seams breaks whatever it landed in the middle of:
|
|
500
|
+
#
|
|
501
|
+
# * a sequence, leaving the introducer stranded and its tail printed as
|
|
502
|
+
# text - the literal "[22m" on screen
|
|
503
|
+
# * a UTF-8 character, which comes out as a replacement glyph
|
|
504
|
+
# * a DECSC the child opened and has not closed yet. The terminal has one
|
|
505
|
+
# save slot; we take it, and the child's own ESC8 then puts its cursor on
|
|
506
|
+
# our bar and it draws the rest of the frame over the top. Claude Code
|
|
507
|
+
# wraps every repaint in ESC7 ... ESC8, so this one is not theoretical.
|
|
508
|
+
#
|
|
509
|
+
# This is only ever asked about the *end* of what we have written so far, so
|
|
510
|
+
# there is no need to understand the sequences - just to know where they stop.
|
|
511
|
+
class OutScan
|
|
512
|
+
ESC = 0x1b
|
|
513
|
+
BEL = 0x07
|
|
514
|
+
|
|
515
|
+
# Escapes whose second byte is followed by exactly one more: SS3 (ESC O),
|
|
516
|
+
# the charset designators (ESC ( ) * +), and ESC # / ESC %.
|
|
517
|
+
ONE_MORE ||= [0x4f, 0x28, 0x29, 0x2a, 0x2b, 0x23, 0x25].freeze
|
|
518
|
+
|
|
519
|
+
# Runaway guards. A CSI this long, or a string payload this long, is a
|
|
520
|
+
# stream we have lost the thread of rather than a sequence still coming.
|
|
521
|
+
MAX_CSI ||= 128
|
|
522
|
+
MAX_STR ||= 8192
|
|
523
|
+
|
|
524
|
+
# Nothing outside these bytes can start a sequence or a multi-byte
|
|
525
|
+
# character, so a plain-text burst needs no walking at all.
|
|
526
|
+
INTERESTING ||= /[\e\x80-\xff]/n
|
|
527
|
+
|
|
528
|
+
def initialize
|
|
529
|
+
reset!
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
def reset!
|
|
533
|
+
@state = :text
|
|
534
|
+
@need = 0 # UTF-8 continuation bytes still owed
|
|
535
|
+
@saved = 0 # ESC7 seen without its ESC8
|
|
536
|
+
@len = 0
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
# True when the last byte written ended a sequence and a character, and the
|
|
540
|
+
# child is not holding a saved cursor.
|
|
541
|
+
def safe?
|
|
542
|
+
@state == :text && @need.zero? && @saved.zero?
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
def feed(bytes)
|
|
546
|
+
return if @state == :text && @need.zero? && !bytes.match?(INTERESTING)
|
|
547
|
+
|
|
548
|
+
bytes.each_byte { |b| step(b) }
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
# What is holding a paint back, for the debug trace.
|
|
552
|
+
def to_s
|
|
553
|
+
"#{@state} utf8=#{@need} saved=#{@saved}"
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
private
|
|
557
|
+
|
|
558
|
+
def step(byte)
|
|
559
|
+
case @state
|
|
560
|
+
when :text then text(byte)
|
|
561
|
+
when :esc then escape(byte)
|
|
562
|
+
when :csi then csi(byte)
|
|
563
|
+
when :one then @state = :text
|
|
564
|
+
when :str then string(byte)
|
|
565
|
+
when :st then terminator(byte)
|
|
566
|
+
end
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
def text(byte)
|
|
570
|
+
return @state = :esc if byte == ESC
|
|
571
|
+
return @need = 0 if byte < 0x80
|
|
572
|
+
|
|
573
|
+
# A continuation byte only counts while one is owed; anything else is a
|
|
574
|
+
# lead byte, and a stray one just resets the count.
|
|
575
|
+
return @need -= 1 if @need.positive? && (byte & 0xc0) == 0x80
|
|
576
|
+
|
|
577
|
+
@need = case byte
|
|
578
|
+
when 0xc0..0xdf then 1
|
|
579
|
+
when 0xe0..0xef then 2
|
|
580
|
+
when 0xf0..0xf7 then 3
|
|
581
|
+
else 0
|
|
582
|
+
end
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
# The second byte says how the rest of the sequence ends. ESC7/ESC8 are the
|
|
586
|
+
# pair we care about beyond that - see the class comment.
|
|
587
|
+
def escape(byte)
|
|
588
|
+
@len = 0
|
|
589
|
+
|
|
590
|
+
case byte
|
|
591
|
+
when 0x5b then @state = :csi
|
|
592
|
+
when *STRING_INTRO then @state = :str
|
|
593
|
+
when *ONE_MORE then @state = :one
|
|
594
|
+
when ESC then nil # ESC ESC: still :esc
|
|
595
|
+
else
|
|
596
|
+
# ESC7 saves the cursor and ESC8 restores it. Everything else that gets
|
|
597
|
+
# here is a two-byte escape, and is over.
|
|
598
|
+
@saved += 1 if byte == 0x37
|
|
599
|
+
@saved -= 1 if byte == 0x38 && @saved.positive?
|
|
600
|
+
@state = :text
|
|
601
|
+
end
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
def csi(byte)
|
|
605
|
+
@len += 1
|
|
606
|
+
return @state = :esc if byte == ESC # aborted, a new one starting
|
|
607
|
+
return @state = :text if @len > MAX_CSI
|
|
608
|
+
|
|
609
|
+
@state = :text if byte >= 0x40 && byte <= 0x7e
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
# String payloads run to BEL or ST (ESC \).
|
|
613
|
+
def string(byte)
|
|
614
|
+
@len += 1
|
|
615
|
+
return @state = :st if byte == ESC
|
|
616
|
+
return @state = :text if byte == BEL || @len > MAX_STR
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
def terminator(byte)
|
|
620
|
+
return if byte == ESC
|
|
621
|
+
|
|
622
|
+
@state = byte == 0x5c ? :text : :str
|
|
623
|
+
end
|
|
624
|
+
end
|
|
625
|
+
|
|
371
626
|
# Rebuilds the line you are typing from the raw byte stream on its way to the
|
|
372
627
|
# child, and keeps the last `keep` submitted lines.
|
|
373
628
|
#
|
|
@@ -387,9 +642,6 @@ module LlmWrap
|
|
|
387
642
|
BEL = 0x07
|
|
388
643
|
DEL = 0x7f
|
|
389
644
|
|
|
390
|
-
# Introducers of the escape sequences that carry a string payload:
|
|
391
|
-
# OSC ], DCS P, SOS X, PM ^, APC _.
|
|
392
|
-
STRING_INTRO ||= [0x5d, 0x50, 0x58, 0x5e, 0x5f].freeze
|
|
393
645
|
CTRL_C = 0x03
|
|
394
646
|
CTRL_U = 0x15
|
|
395
647
|
CTRL_W = 0x17
|
data/recipes/llm.rb
CHANGED
|
@@ -7,6 +7,7 @@ desc <<~TXT
|
|
|
7
7
|
|
|
8
8
|
Namespaces:
|
|
9
9
|
memory persistent memory store (backs the Claude Code memory plugin)
|
|
10
|
+
plan apply a /plan bundle - sha1 checked, drift aware
|
|
10
11
|
prompt token-prefix prompt expander (UserPromptSubmit hook + CLI)
|
|
11
12
|
|
|
12
13
|
Commands:
|
|
@@ -23,6 +24,7 @@ require 'set'
|
|
|
23
24
|
# checkout still load matching lib/, not an older installed gem copy).
|
|
24
25
|
_llm_root = File.dirname(File.realpath(__FILE__))
|
|
25
26
|
require File.join(_llm_root, 'lib/llm/usage')
|
|
27
|
+
require File.join(_llm_root, 'lib/llm/plan')
|
|
26
28
|
|
|
27
29
|
STORE ||= ENV['CLAUDE_MEMORY_STORE'] || File.expand_path('~/dev/ai/memory')
|
|
28
30
|
VALID_TYPES ||= %w[user feedback project reference].freeze
|
|
@@ -40,10 +42,10 @@ task :usage do
|
|
|
40
42
|
`~/.cache/llm/claude-limits.json`, falling back to the OAuth usage API. `month` shows
|
|
41
43
|
Claude extra-usage credits (API only, and only when you've enabled them).
|
|
42
44
|
D
|
|
43
|
-
example '
|
|
44
|
-
example '
|
|
45
|
-
example '
|
|
46
|
-
example '
|
|
45
|
+
example 'usage'
|
|
46
|
+
example 'usage month'
|
|
47
|
+
example 'usage --json'
|
|
48
|
+
example 'usage --provider grok'
|
|
47
49
|
|
|
48
50
|
# :period is declared first on purpose — the parser fills un-set scalar opts
|
|
49
51
|
# from positionals in declaration order, so `llm usage month` must reach
|
|
@@ -91,7 +93,7 @@ task :wrap do
|
|
|
91
93
|
newest one sits on the very last line. It is --lines + 1 rows tall.
|
|
92
94
|
|
|
93
95
|
Each prompt gets one row: a multi-line prompt is flattened onto it with line breaks
|
|
94
|
-
shown as a
|
|
96
|
+
shown as a backslash, and the whole thing is clipped to the terminal width.
|
|
95
97
|
|
|
96
98
|
A prompt is whatever you type between Enters. Shift/Option+Enter and pasted newlines
|
|
97
99
|
keep appending to the current one. This is keystroke sniffing, so text the program
|
|
@@ -108,27 +110,59 @@ task :wrap do
|
|
|
108
110
|
|
|
109
111
|
Put `--` before the wrapped command when it has flags of its own, otherwise they are
|
|
110
112
|
parsed as flags to `llm wrap`.
|
|
113
|
+
|
|
114
|
+
With no command at all, the commands in ~/.config/hammer/llm-wrap.txt are offered in
|
|
115
|
+
an arrow-key picker instead. That file is plain text, one command per line. A line
|
|
116
|
+
starting with # is dropped, and a line with no letter in it stays on screen but
|
|
117
|
+
cannot be picked - so blanks and ---- rules group the list without being in the way.
|
|
118
|
+
`--config` opens it in $EDITOR, writing a starting set the first time.
|
|
111
119
|
D
|
|
112
|
-
example '
|
|
113
|
-
example '
|
|
114
|
-
example '
|
|
120
|
+
example 'wrap'
|
|
121
|
+
example 'wrap --config'
|
|
122
|
+
example 'wrap claude'
|
|
123
|
+
example 'wrap -- claude --resume'
|
|
124
|
+
example 'wrap --lines 5 -- bash -l'
|
|
115
125
|
|
|
116
126
|
# `positional: false` is load-bearing: the parser fills un-set scalar opts
|
|
117
127
|
# from positionals in declaration order, so without it `llm wrap claude`
|
|
118
|
-
# would hand "claude" to --lines and die on the integer cast.
|
|
128
|
+
# would hand "claude" to --lines and die on the integer cast. `--config` is
|
|
129
|
+
# boolean, which the parser skips over, so it needs no such guard.
|
|
119
130
|
opt :lines, type: :integer, default: 3, positional: false,
|
|
120
131
|
desc: 'how many recent prompts to pin', placeholder: 'N'
|
|
132
|
+
opt :config, type: :boolean, desc: 'open the command list in $EDITOR'
|
|
121
133
|
|
|
122
134
|
proc do |opts|
|
|
123
|
-
cmd = Array(opts[:args])
|
|
124
|
-
error 'usage: llm wrap [--lines N] [--] <command> [args...]' if cmd.empty?
|
|
125
|
-
|
|
126
135
|
# Required lazily - llm.rb also runs as a per-prompt hook (`llm prompt
|
|
127
136
|
# hook`), which should not pay to load pty/io-console. `_llm_root` is a
|
|
128
137
|
# local from the file scope; the block closes over it (instance_exec
|
|
129
138
|
# rebinds self, not locals).
|
|
130
139
|
require File.join(_llm_root, 'lib/llm/wrap')
|
|
131
140
|
|
|
141
|
+
if opts[:config]
|
|
142
|
+
editor = ENV['EDITOR'] || ENV['VISUAL']
|
|
143
|
+
error '$EDITOR not set' unless editor
|
|
144
|
+
say.green "created #{LlmWrap::Config.path}" if LlmWrap::Config.seed
|
|
145
|
+
exit system(editor, LlmWrap::Config.path) ? 0 : 1
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
cmd = Array(opts[:args])
|
|
149
|
+
|
|
150
|
+
# Nothing asked for: offer the list rather than a usage error. The picker
|
|
151
|
+
# shows each line as it was written - re-joining a split argv would quote
|
|
152
|
+
# it back differently from what is in the file.
|
|
153
|
+
if cmd.empty?
|
|
154
|
+
LlmWrap::Config.seed
|
|
155
|
+
choices = LlmWrap::Config.lines
|
|
156
|
+
unless choices.any? { |line| LlmWrap::Config.runnable?(line) }
|
|
157
|
+
error "no commands in #{LlmWrap::Config.path} - add one with: llm wrap --config"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
idx = choose('wrap which command?', choices, skip: ->(line) { !LlmWrap::Config.runnable?(line) })
|
|
161
|
+
next say.gray('cancelled') unless idx
|
|
162
|
+
|
|
163
|
+
cmd = LlmWrap::Config.argv(choices[idx])
|
|
164
|
+
end
|
|
165
|
+
|
|
132
166
|
# The wrapper renames itself after the program it runs, so that a pane
|
|
133
167
|
# watcher can still see which agent is in there - which leaves nothing
|
|
134
168
|
# behind that says how the pane was started. This is that: the invocation
|
|
@@ -179,7 +213,7 @@ namespace :memory do
|
|
|
179
213
|
|
|
180
214
|
task :list do
|
|
181
215
|
desc 'List stored memories with type and one-line description'
|
|
182
|
-
example '
|
|
216
|
+
example 'memory list'
|
|
183
217
|
|
|
184
218
|
proc do
|
|
185
219
|
files = Dir[File.join(STORE, '*.md')].sort
|
|
@@ -199,7 +233,7 @@ namespace :memory do
|
|
|
199
233
|
|
|
200
234
|
task :read do
|
|
201
235
|
desc 'Print the full content of a memory (frontmatter + body)'
|
|
202
|
-
example '
|
|
236
|
+
example 'memory read user-role'
|
|
203
237
|
|
|
204
238
|
proc do |opts|
|
|
205
239
|
name = opts[:args].first
|
|
@@ -251,7 +285,7 @@ namespace :memory do
|
|
|
251
285
|
|
|
252
286
|
task :delete do
|
|
253
287
|
desc 'Delete a memory by name'
|
|
254
|
-
example '
|
|
288
|
+
example 'memory delete old-fact'
|
|
255
289
|
|
|
256
290
|
proc do |opts|
|
|
257
291
|
name = opts[:args].first
|
|
@@ -265,7 +299,7 @@ namespace :memory do
|
|
|
265
299
|
|
|
266
300
|
task :search do
|
|
267
301
|
desc 'Search memory bodies for a query string (case-insensitive)'
|
|
268
|
-
example '
|
|
302
|
+
example 'memory search react'
|
|
269
303
|
|
|
270
304
|
proc do |opts|
|
|
271
305
|
query = opts[:args].first
|
|
@@ -524,8 +558,8 @@ namespace :prompt do
|
|
|
524
558
|
|
|
525
559
|
task :expand do
|
|
526
560
|
desc 'Expand prompt token(s) and print the resulting context'
|
|
527
|
-
example '
|
|
528
|
-
example '
|
|
561
|
+
example 'prompt:expand :foo :bar'
|
|
562
|
+
example 'prompt:expand foo:'
|
|
529
563
|
|
|
530
564
|
proc do |opts|
|
|
531
565
|
input = opts[:args].join(' ')
|
|
@@ -561,3 +595,104 @@ namespace :prompt do
|
|
|
561
595
|
end
|
|
562
596
|
end
|
|
563
597
|
end
|
|
598
|
+
|
|
599
|
+
# The namespace's sibling task: `llm plan` explains the thing, `llm plan:*`
|
|
600
|
+
# does it. The command list underneath is hammer's own, not a copy.
|
|
601
|
+
task :plan do
|
|
602
|
+
desc 'How the /plan bundle apply works: logic, bundle format, exit codes'
|
|
603
|
+
example 'plan'
|
|
604
|
+
|
|
605
|
+
proc do
|
|
606
|
+
say LlmPlan.readme
|
|
607
|
+
say ''
|
|
608
|
+
self.class.print_help 'plan:'
|
|
609
|
+
end
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
namespace :plan do
|
|
613
|
+
# Helpers live inside the namespace block for the same reason as :memory -
|
|
614
|
+
# a top-level `helpers do` lands on the root class, which namespaces do not
|
|
615
|
+
# inherit from.
|
|
616
|
+
private
|
|
617
|
+
|
|
618
|
+
def load_bundle(opts)
|
|
619
|
+
path = opts[:args].first
|
|
620
|
+
error 'usage: llm plan:<apply|check|verify|revert> ./tmp/plan-[SLUG].json' unless path
|
|
621
|
+
LlmPlan::Bundle.new(path)
|
|
622
|
+
rescue LlmPlan::Error => e
|
|
623
|
+
error e.message
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
# Hammer owns colour (and turns it off for a non-tty), so hand its painter
|
|
627
|
+
# to the report rather than teaching the report about ANSI.
|
|
628
|
+
def render(outcome)
|
|
629
|
+
report = LlmPlan::Report.new(outcome, paint: Hammer::Shell.method(:paint))
|
|
630
|
+
report.lines.each { |text, color| say text, color }
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
def run_verify(runner)
|
|
634
|
+
result = runner.verify { |cmd| say " > #{cmd}", :cyan }
|
|
635
|
+
result.ok ? say(' verify ok', :green) : say(" FAIL #{result.failed_command}", :red)
|
|
636
|
+
result
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
task :apply do
|
|
640
|
+
# Composed from plan.md rather than written out again - see `llm plan`.
|
|
641
|
+
desc ['Apply a /plan bundle: sha1-checked edits now, drift handed back to you.',
|
|
642
|
+
"The bundle, normally ./tmp/plan-[SLUG].json:\n\n#{LlmPlan.section('The bundle')}",
|
|
643
|
+
LlmPlan.section('Drift'),
|
|
644
|
+
LlmPlan.section('Exit codes')].join("\n\n")
|
|
645
|
+
example 'plan:apply ./tmp/plan-note-anchor.json'
|
|
646
|
+
|
|
647
|
+
proc do |opts|
|
|
648
|
+
bundle = load_bundle(opts)
|
|
649
|
+
runner = LlmPlan::Runner.new(bundle)
|
|
650
|
+
outcome = runner.apply
|
|
651
|
+
|
|
652
|
+
render outcome
|
|
653
|
+
outcome.verify = run_verify(runner) if outcome.clean?
|
|
654
|
+
|
|
655
|
+
exit outcome.exit_code
|
|
656
|
+
end
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
task :check do
|
|
660
|
+
desc ['Dry run: the summary to read before approving a plan. Writes nothing.',
|
|
661
|
+
LlmPlan.section('Summary')].join("\n\n")
|
|
662
|
+
example 'plan:check ./tmp/plan-note-anchor.json'
|
|
663
|
+
example 'plan:check --md ./tmp/plan-note-anchor.json'
|
|
664
|
+
|
|
665
|
+
opt :md, type: :boolean, desc: 'emit the summary as markdown, to paste into a reply'
|
|
666
|
+
|
|
667
|
+
proc do |opts|
|
|
668
|
+
outcome = LlmPlan::Runner.new(load_bundle(opts)).apply(check_only: true)
|
|
669
|
+
opts[:md] ? puts(LlmPlan::MarkdownReport.new(outcome).to_s) : render(outcome)
|
|
670
|
+
exit outcome.exit_code
|
|
671
|
+
end
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
task :verify do
|
|
675
|
+
desc <<~D
|
|
676
|
+
Run only the bundle's verify commands.
|
|
677
|
+
|
|
678
|
+
For after you have closed a drift by hand, or fixed what a failing verify
|
|
679
|
+
caught. Exit 0 green, 20 failed.
|
|
680
|
+
D
|
|
681
|
+
example 'plan:verify ./tmp/plan-note-anchor.json'
|
|
682
|
+
|
|
683
|
+
proc do |opts|
|
|
684
|
+
exit run_verify(LlmPlan::Runner.new(load_bundle(opts))).ok ? 0 : 20
|
|
685
|
+
end
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
task :revert do
|
|
689
|
+
desc 'Undo an applied bundle from <slug>.bak: restore changed and deleted files, remove created ones.'
|
|
690
|
+
example 'plan:revert ./tmp/plan-note-anchor.json'
|
|
691
|
+
|
|
692
|
+
proc do |opts|
|
|
693
|
+
LlmPlan::Runner.new(load_bundle(opts)).revert.each { |path| say " restored #{path}", :green }
|
|
694
|
+
rescue LlmPlan::Error => e
|
|
695
|
+
error e.message
|
|
696
|
+
end
|
|
697
|
+
end
|
|
698
|
+
end
|
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.
|
|
4
|
+
version: 0.3.22
|
|
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-
|
|
11
|
+
date: 2026-08-09 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: minitest
|
|
@@ -55,15 +55,7 @@ files:
|
|
|
55
55
|
- "./lib/lux-hammer.rb"
|
|
56
56
|
- "./recipes/deploy.rb"
|
|
57
57
|
- "./recipes/git-helper.rb"
|
|
58
|
-
- "./recipes/lib/
|
|
59
|
-
- "./recipes/lib/deploy/commands.rb"
|
|
60
|
-
- "./recipes/lib/deploy/config.rb"
|
|
61
|
-
- "./recipes/lib/deploy/context.rb"
|
|
62
|
-
- "./recipes/lib/deploy/doctor.rb"
|
|
63
|
-
- "./recipes/lib/deploy/hammer.rb"
|
|
64
|
-
- "./recipes/lib/deploy/manifest.rb"
|
|
65
|
-
- "./recipes/lib/deploy/ssh.rb"
|
|
66
|
-
- "./recipes/lib/deploy/template.rb"
|
|
58
|
+
- "./recipes/lib/llm/plan.rb"
|
|
67
59
|
- "./recipes/lib/llm/usage.rb"
|
|
68
60
|
- "./recipes/lib/llm/wrap.rb"
|
|
69
61
|
- "./recipes/llm.rb"
|
data/recipes/lib/deploy/boot.rb
DELETED
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
require 'fileutils'
|
|
2
|
-
require 'pathname'
|
|
3
|
-
require 'open3'
|
|
4
|
-
require 'set'
|
|
5
|
-
require 'shellwords'
|
|
6
|
-
require 'yaml'
|
|
7
|
-
|
|
8
|
-
module LuxDeploy
|
|
9
|
-
# Recipe layout: this file and its siblings live under
|
|
10
|
-
# recipes/lib/deploy/, so ROOT points here and `templates/` sits
|
|
11
|
-
# right next to us (no gem root anymore).
|
|
12
|
-
ROOT ||= Pathname.new(__dir__)
|
|
13
|
-
VERSION ||= '0.2.0'
|
|
14
|
-
|
|
15
|
-
# Branches that select `.env` instead of `.env.staging`.
|
|
16
|
-
MAIN_BRANCHES ||= %w[master main]
|
|
17
|
-
|
|
18
|
-
# Server-side conventions. Not config-tunable because doctor and the
|
|
19
|
-
# deploy flow both hardcode these paths in the host setup. A different
|
|
20
|
-
# caddy/systemd layout means a different recipe.
|
|
21
|
-
PORT_RANGE ||= (3010..3990).step(10).to_a
|
|
22
|
-
CADDY_SITES ||= '/etc/caddy/sites'
|
|
23
|
-
SYSTEMD_DIR ||= '/etc/systemd/system'
|
|
24
|
-
|
|
25
|
-
class Error < StandardError
|
|
26
|
-
def to_s
|
|
27
|
-
"ERROR: #{super}"
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
# Host-supplied defaults that sit under the user's .yaml. Set once by a
|
|
32
|
-
# wrapping plugin/Hammerfile (e.g. lux-fw seeds 'lux-web' / 'lux-apps'),
|
|
33
|
-
# consumed by Config.new. Empty by default so the recipe stays "generic".
|
|
34
|
-
@defaults = {}
|
|
35
|
-
|
|
36
|
-
class << self
|
|
37
|
-
attr_reader :defaults
|
|
38
|
-
|
|
39
|
-
def set_defaults(hash)
|
|
40
|
-
@defaults = (hash || {}).each_with_object({}) { |(k, v), h| h[k.to_s] = v }
|
|
41
|
-
end
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
require_relative 'config'
|
|
46
|
-
require_relative 'ssh'
|
|
47
|
-
require_relative 'template'
|
|
48
|
-
require_relative 'doctor'
|
|
49
|
-
require_relative 'context'
|
|
50
|
-
require_relative 'manifest'
|
|
51
|
-
require_relative 'commands'
|
|
52
|
-
require_relative 'hammer'
|