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.
@@ -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
- draw_bar if @dirty # output settled
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 = slurp(@pty_out) or return
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
- draw_bar if @dirty && now - @drawn_at > MAX_DEFER
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 'llm usage'
44
- example 'llm usage month'
45
- example 'llm usage --json'
46
- example 'llm usage --provider grok'
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 literal \\n, and the whole thing is clipped to the terminal width.
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 'llm wrap claude'
113
- example 'llm wrap -- claude --resume'
114
- example 'llm wrap --lines 5 -- bash -l'
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 'llm memory list'
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 'llm memory read user-role'
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 'llm memory delete old-fact'
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 'llm memory search react'
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 'llm prompt:expand :foo :bar'
528
- example 'llm prompt:expand foo:'
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.20
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-07-26 00:00:00.000000000 Z
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/deploy/boot.rb"
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"
@@ -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'