lux-hammer 0.3.13 → 0.3.15

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.
@@ -0,0 +1,304 @@
1
+ require 'socket'
2
+ require 'cgi'
3
+ require 'json'
4
+
5
+ class Hammer
6
+ # Localhost web UI for Hammer::CronServer. Hand-rolled HTTP on stdlib
7
+ # TCPServer - the gem has no runtime dependencies and the protocol
8
+ # surface needed here is tiny: parse the request line, drain headers,
9
+ # route on verb+path, answer with Connection: close. No keep-alive, no
10
+ # chunked encoding, one short-lived thread per request so a slow
11
+ # client cannot block the scheduler or other viewers.
12
+ #
13
+ # Routes:
14
+ # GET / jobs table (schedule, last/next run, status)
15
+ # GET /job/<path> log viewer; ?file=1 shows the rotated log
16
+ # POST /job/<path>/run trigger a manual run, redirect back (303)
17
+ # GET /json live status as JSON, for scripting
18
+ class CronWeb
19
+ REFRESH_SECONDS ||= 10
20
+ TAIL_BYTES ||= 65_536 # log viewer shows at most the last 64 KB
21
+
22
+ def initialize(server, port:, pass: nil)
23
+ @server = server
24
+ @port = port
25
+ @pass = pass && !pass.empty? ? pass : nil
26
+ end
27
+
28
+ # Actual bound port - differs from the requested one only when the
29
+ # caller asked for port 0 (tests grab a free ephemeral port that way).
30
+ def bound_port
31
+ @tcp&.addr&.fetch(1)
32
+ end
33
+
34
+ # Bind 127.0.0.1 only - the UI can trigger task runs, it must never
35
+ # listen on a public interface. Returns self so the caller can hold
36
+ # the instance for #stop.
37
+ def start
38
+ @tcp = TCPServer.new('127.0.0.1', @port)
39
+ @thread = Thread.new do
40
+ loop do
41
+ sock = begin
42
+ @tcp.accept
43
+ rescue IOError, Errno::EBADF
44
+ break # #stop closed the listener
45
+ end
46
+ Thread.new(sock) do |s|
47
+ begin
48
+ handle(s)
49
+ rescue StandardError
50
+ nil # a broken client must not take the UI down
51
+ ensure
52
+ s.close rescue nil
53
+ end
54
+ end
55
+ end
56
+ end
57
+ self
58
+ rescue Errno::EADDRINUSE
59
+ raise Hammer::Error, "h:cron web ui: port #{@port} is taken - pick another with --port"
60
+ end
61
+
62
+ # Closing the listener pops the accept loop out with IOError.
63
+ def stop
64
+ @tcp&.close
65
+ @thread&.join(2)
66
+ end
67
+
68
+ private
69
+
70
+ # Minimal HTTP: request line + headers, drain any Content-Length body
71
+ # (our POSTs carry none we care about), then route. With a --pass
72
+ # configured every route requires HTTP Basic auth first.
73
+ def handle(sock)
74
+ line = sock.gets or return
75
+ verb, target, = line.split(' ', 3)
76
+ length = 0
77
+ auth = nil
78
+ while (h = sock.gets) && h.strip != ''
79
+ length = h.split(':', 2).last.to_i if h =~ /\Acontent-length:/i
80
+ auth = h.split(':', 2).last.strip if h =~ /\Aauthorization:/i
81
+ end
82
+ sock.read(length) if length > 0
83
+
84
+ return unauthorized(sock) if @pass && !authorized?(auth)
85
+
86
+ path, query = target.to_s.split('?', 2)
87
+ route(sock, verb, CGI.unescape(path.to_s), query.to_s)
88
+ end
89
+
90
+ # Only the password half of `user:pass` is checked - any username
91
+ # works (`curl -u :secret`). Constant-time compare so the password
92
+ # can't be guessed byte-by-byte from response timing.
93
+ def authorized?(header)
94
+ return false unless header && header =~ /\ABasic (.+)\z/
95
+ decoded = begin
96
+ Regexp.last_match(1).unpack1('m')
97
+ rescue ArgumentError
98
+ return false
99
+ end
100
+ pass = decoded.to_s.split(':', 2)[1].to_s
101
+ secure_compare(pass, @pass)
102
+ end
103
+
104
+ def secure_compare(a, b)
105
+ return false unless a.bytesize == b.bytesize
106
+ diff = 0
107
+ a.bytes.zip(b.bytes) { |x, y| diff |= x ^ y }
108
+ diff.zero?
109
+ end
110
+
111
+ # 401 + the WWW-Authenticate challenge that makes browsers pop the
112
+ # native login prompt.
113
+ def unauthorized(sock)
114
+ body = layout('unauthorized', '<p>401 - password required</p>')
115
+ sock.write "HTTP/1.1 401 Unauthorized\r\n" \
116
+ "WWW-Authenticate: Basic realm=\"h:cron\"\r\n" \
117
+ "Content-Type: text/html; charset=utf-8\r\n" \
118
+ "Content-Length: #{body.bytesize}\r\n" \
119
+ "Connection: close\r\n\r\n"
120
+ sock.write body
121
+ end
122
+
123
+ def route(sock, verb, path, query)
124
+ case
125
+ when verb == 'GET' && path == '/'
126
+ respond(sock, 200, layout('jobs', index_html))
127
+ when verb == 'GET' && path == '/json'
128
+ respond(sock, 200, JSON.pretty_generate(json_status), type: 'application/json')
129
+ when verb == 'POST' && path =~ %r{\A/job/(.+)/run\z}
130
+ job_path = Regexp.last_match(1)
131
+ if @server.run_now(job_path)
132
+ redirect(sock, "/job/#{CGI.escape(job_path)}")
133
+ else
134
+ respond(sock, 404, layout('not found', "<p>unknown job #{h(job_path)}</p>"))
135
+ end
136
+ when verb == 'GET' && path =~ %r{\A/job/(.+)\z}
137
+ job_path = Regexp.last_match(1)
138
+ if @server.job?(job_path)
139
+ respond(sock, 200, layout(job_path, job_html(job_path, rotated: query == 'file=1')))
140
+ else
141
+ respond(sock, 404, layout('not found', "<p>unknown job #{h(job_path)}</p>"))
142
+ end
143
+ else
144
+ respond(sock, 404, layout('not found', '<p>404</p>'))
145
+ end
146
+ end
147
+
148
+ # ----- responses -------------------------------------------------------
149
+
150
+ def respond(sock, status, body, type: 'text/html; charset=utf-8')
151
+ text = { 200 => 'OK', 303 => 'See Other', 404 => 'Not Found' }[status] || 'OK'
152
+ sock.write "HTTP/1.1 #{status} #{text}\r\n" \
153
+ "Content-Type: #{type}\r\n" \
154
+ "Content-Length: #{body.bytesize}\r\n" \
155
+ "Connection: close\r\n\r\n"
156
+ sock.write body
157
+ end
158
+
159
+ # Post-redirect-get: the browser lands back on a plain GET, so a
160
+ # refresh never re-triggers the run.
161
+ def redirect(sock, location)
162
+ sock.write "HTTP/1.1 303 See Other\r\n" \
163
+ "Location: #{location}\r\n" \
164
+ "Content-Length: 0\r\n" \
165
+ "Connection: close\r\n\r\n"
166
+ end
167
+
168
+ # ----- views -----------------------------------------------------------
169
+
170
+ def index_html
171
+ rows = @server.snapshot.map do |j|
172
+ badge = status_badge(j)
173
+ <<~ROW
174
+ <tr>
175
+ <td><a href="/job/#{CGI.escape(j[:path])}">#{h(j[:path])}</a></td>
176
+ <td><code>#{h(j[:cron])}</code></td>
177
+ <td>#{h(fmt_time(j[:last_run]))}</td>
178
+ <td>#{badge}</td>
179
+ <td>#{h(fmt_time(j[:next_run]))}</td>
180
+ <td>
181
+ <form method="post" action="/job/#{CGI.escape(j[:path])}/run">
182
+ <button#{j[:running] ? ' disabled' : ''}>run now</button>
183
+ </form>
184
+ </td>
185
+ </tr>
186
+ ROW
187
+ end
188
+ <<~HTML
189
+ <table>
190
+ <tr><th>task</th><th>schedule</th><th>last run</th><th>status</th><th>next run</th><th></th></tr>
191
+ #{rows.join}
192
+ </table>
193
+ HTML
194
+ end
195
+
196
+ def job_html(path, rotated: false)
197
+ job = @server.snapshot.find { |j| j[:path] == path }
198
+ file = log_file(path, rotated: rotated)
199
+ log = tail_file(file)
200
+ <<~HTML
201
+ <p><a href="/">&larr; all jobs</a></p>
202
+ <h2>#{h(path)}</h2>
203
+ <p>
204
+ <code>#{h(job[:cron])}</code> &middot;
205
+ #{status_badge(job)} &middot;
206
+ last run #{h(fmt_time(job[:last_run]))} &middot;
207
+ next run #{h(fmt_time(job[:next_run]))}
208
+ #{job[:duration] ? "&middot; took #{format('%.1f', job[:duration])}s" : ''}
209
+ </p>
210
+ <form method="post" action="/job/#{CGI.escape(path)}/run">
211
+ <button#{job[:running] ? ' disabled' : ''}>run now</button>
212
+ </form>
213
+ <p class="files">
214
+ #{rotated ? "<a href=\"/job/#{CGI.escape(path)}\">current log</a>" : '<b>current log</b>'} |
215
+ #{rotated ? '<b>rotated log</b>' : "<a href=\"/job/#{CGI.escape(path)}?file=1\">rotated log</a>"}
216
+ </p>
217
+ <pre>#{h(log)}</pre>
218
+ HTML
219
+ end
220
+
221
+ def layout(title, body)
222
+ <<~HTML
223
+ <!doctype html>
224
+ <html>
225
+ <head>
226
+ <meta charset="utf-8">
227
+ <meta http-equiv="refresh" content="#{REFRESH_SECONDS}">
228
+ <title>h:cron - #{h(title)}</title>
229
+ <style>
230
+ body { font: 14px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
231
+ margin: 2rem auto; max-width: 60rem; padding: 0 1rem; color: #222; }
232
+ h1 a { color: inherit; text-decoration: none; }
233
+ table { border-collapse: collapse; width: 100%; }
234
+ th, td { text-align: left; padding: .4rem .8rem; border-bottom: 1px solid #ddd; }
235
+ th { color: #888; font-weight: 600; }
236
+ code { background: #f4f4f4; padding: .1rem .3rem; border-radius: 3px; }
237
+ pre { background: #1a1a1a; color: #ddd; padding: 1rem; border-radius: 6px;
238
+ overflow-x: auto; white-space: pre-wrap; word-break: break-all; }
239
+ button { cursor: pointer; }
240
+ .ok { color: #2a7d2a; }
241
+ .failed, .error { color: #c02626; }
242
+ .running { color: #b8860b; }
243
+ .never, .unknown { color: #888; }
244
+ .files { color: #888; }
245
+ footer { color: #aaa; margin-top: 2rem; font-size: 12px; }
246
+ @media (prefers-color-scheme: dark) {
247
+ body { background: #111; color: #ccc; }
248
+ th, td { border-color: #333; }
249
+ code { background: #222; }
250
+ }
251
+ </style>
252
+ </head>
253
+ <body>
254
+ <h1><a href="/">h:cron</a></h1>
255
+ #{body}
256
+ <footer>hammer #{Hammer::VERSION} &middot; #{h(@server.root_dir)} &middot; refreshes every #{REFRESH_SECONDS}s</footer>
257
+ </body>
258
+ </html>
259
+ HTML
260
+ end
261
+
262
+ def status_badge(j)
263
+ label = j[:running] ? 'running' : (j[:status] || 'never ran')
264
+ css = j[:running] ? 'running' : (j[:status] || 'never')
265
+ extra = !j[:running] && j[:exit_code] ? " (exit #{j[:exit_code]})" : ''
266
+ "<span class=\"#{h(css)}\">#{h(label)}#{h(extra)}</span>"
267
+ end
268
+
269
+ def json_status
270
+ @server.snapshot.map do |j|
271
+ j.merge(last_run: j[:last_run]&.to_i, next_run: j[:next_run]&.to_i)
272
+ end
273
+ end
274
+
275
+ # ----- helpers ----------------------------------------------------------
276
+
277
+ def log_file(path, rotated: false)
278
+ slug = path.tr(':', '-').gsub(/[^\w.-]/, '-')
279
+ file = File.join(@server.root_dir, 'log/hammer', "#{slug}.log")
280
+ rotated ? "#{file}.1" : file
281
+ end
282
+
283
+ # Last TAIL_BYTES of the file - enough for a viewer, cheap even when
284
+ # the log sits right under the 1 MB rotation cap.
285
+ def tail_file(file)
286
+ size = File.size(file)
287
+ File.open(file) do |f|
288
+ f.seek([size - TAIL_BYTES, 0].max)
289
+ data = f.read.to_s
290
+ size > TAIL_BYTES ? "... (truncated)\n#{data}" : data
291
+ end
292
+ rescue Errno::ENOENT
293
+ '(no log yet)'
294
+ end
295
+
296
+ def fmt_time(t)
297
+ t ? t.strftime('%F %T') : 'never'
298
+ end
299
+
300
+ def h(text)
301
+ CGI.escapeHTML(text.to_s)
302
+ end
303
+ end
304
+ end
data/lib/hammer/option.rb CHANGED
@@ -78,5 +78,24 @@ class Hammer
78
78
  return '' if default.nil?
79
79
  "(default: #{default.inspect})"
80
80
  end
81
+
82
+ # Structured form for JSON export (`h:json`). The GUI maps `type`
83
+ # to a form widget; `default`/`required`/`desc` decorate it. Mirrors
84
+ # the data behind `usage`. `negation` is only meaningful for booleans.
85
+ def to_h
86
+ h = {
87
+ name: name.to_s,
88
+ type: type.to_s,
89
+ default: default,
90
+ required: required,
91
+ desc: desc,
92
+ placeholder: placeholder,
93
+ switch: switch,
94
+ aliases: aliases,
95
+ usage: usage.strip
96
+ }
97
+ h[:negation] = negation if boolean?
98
+ h
99
+ end
81
100
  end
82
101
  end
data/lib/lux-hammer.rb CHANGED
@@ -2,6 +2,7 @@ require_relative 'hammer/shell'
2
2
  require_relative 'hammer/option'
3
3
  require_relative 'hammer/parser'
4
4
  require_relative 'hammer/command'
5
+ require_relative 'hammer/cron'
5
6
  require_relative 'hammer/loader'
6
7
  require_relative 'hammer/builder'
7
8
  require_relative 'hammer/command_builder'
@@ -62,6 +63,7 @@ class Hammer
62
63
  sub.instance_variable_set(:@pending_options, [])
63
64
  sub.instance_variable_set(:@pending_alts, [])
64
65
  sub.instance_variable_set(:@pending_needs, [])
66
+ sub.instance_variable_set(:@pending_cron, nil)
65
67
  end
66
68
 
67
69
  # ----- class-level DSL for `def`-style commands ---------------------
@@ -80,6 +82,7 @@ class Hammer
80
82
  def opt(name, **o) ; @pending_options << Option.new(name, **o) end
81
83
  def alt(*names) ; @pending_alts.concat(names) end
82
84
  def needs(*names) ; @pending_needs.concat(names) end
85
+ def cron(expr) ; @pending_cron = expr end
83
86
 
84
87
  def method_added(method_name)
85
88
  super
@@ -90,6 +93,7 @@ class Hammer
90
93
  @pending_options.each { |o| cmd.add_option(o) }
91
94
  @pending_alts.each { |n| cmd.add_alt(n) }
92
95
  @pending_needs.each { |n| cmd.add_need(n) }
96
+ cmd.set_cron(@pending_cron) if @pending_cron
93
97
 
94
98
  # If the method takes no args, call it without opts. Otherwise pass
95
99
  # opts. So both `def build` and `def build(opts)` work.
@@ -104,6 +108,7 @@ class Hammer
104
108
  @pending_options = []
105
109
  @pending_alts = []
106
110
  @pending_needs = []
111
+ @pending_cron = nil
107
112
  end
108
113
 
109
114
  # Resolved lazily on first read and memoized, so callers that need the
@@ -183,6 +188,7 @@ class Hammer
183
188
  @pending_options = []
184
189
  @pending_alts = []
185
190
  @pending_needs = []
191
+ @pending_cron = nil
186
192
  end
187
193
 
188
194
  # Open a namespace (group of commands). Everything inside the block
@@ -583,6 +589,39 @@ class Hammer
583
589
  end
584
590
  end
585
591
 
592
+ # Machine-readable spec for `h:json` -> the macOS GUI (and, later,
593
+ # for lux itself to render the default listing). One hash:
594
+ # commands => { group => { full_path => task_meta } }
595
+ # Grouping/sort mirror the bare-`hammer` listing exactly: group by
596
+ # the first namespace segment (a bare task sharing a namespace's name
597
+ # joins that group via section_for), root tasks under "__root",
598
+ # "__root" first, remaining groups in first-encounter order, tasks
599
+ # within a group by [depth, name]. Hidden (no-`desc`) tasks are
600
+ # skipped and the reserved `h:` tree is pruned unless include_builtins.
601
+ def export_spec(include_builtins: false)
602
+ groups = {} # group => { full_path => meta }, in first-encounter order
603
+
604
+ each_command(include_builtins: include_builtins) do |path, c|
605
+ next if c.desc.empty?
606
+ section = section_for(path, nil, self)
607
+ key = section == :root ? '__root' : section.to_s
608
+ (groups[key] ||= {})[path] = c.to_h(path)
609
+ end
610
+
611
+ sort_tasks = ->(h) { h.sort_by { |p, _| [p.count(':'), p] }.to_h }
612
+ ordered = {}
613
+ ordered['__root'] = sort_tasks.call(groups.delete('__root')) if groups.key?('__root')
614
+ groups.each { |k, v| ordered[k] = sort_tasks.call(v) }
615
+
616
+ {
617
+ schema: 1,
618
+ hammer_version: VERSION,
619
+ program_name: program_name,
620
+ app_desc: app_desc,
621
+ commands: ordered
622
+ }
623
+ end
624
+
586
625
  def run_command(cmd, argv, full: nil, quiet: false)
587
626
  # -h / --help is reserved on every command. Anywhere before a `--`
588
627
  # stop-marker, it short-circuits to per-command help.
@@ -622,7 +661,9 @@ class Hammer
622
661
  end
623
662
  end
624
663
  parts.concat(positional)
625
- Shell.say "> #{parts.join(' ')}", :gray
664
+ # Diagnostic, not program output - to stderr so stdout stays clean
665
+ # for machine-readable tasks (`h:json`, `h:version`) and pipes.
666
+ warn Shell.paint("> #{parts.join(' ')}", :gray)
626
667
  end
627
668
 
628
669
  # Fire `before` hooks from root down through the namespace chain.
@@ -703,7 +744,7 @@ class Hammer
703
744
  each_command(include_builtins: extended) { |path, c| print_full_block(path, c) unless c.desc.empty? }
704
745
  else
705
746
  Shell.say ''
706
- print_command_list(self, include_builtins: extended)
747
+ print_command_list(include_builtins: extended)
707
748
  end
708
749
  print_recipes_section if extended && root.instance_variable_get(:@hammer_binary)
709
750
  print_extras if extended
@@ -736,8 +777,8 @@ class Hammer
736
777
  Shell.say "Usage: #{program_name} #{prefix}:COMMAND [ARGS]", :cyan
737
778
  rows = []
738
779
  sibling = find_namespace_sibling(prefix)
739
- rows << [prefix, sibling] if sibling && !sibling.desc.empty?
740
- ns.each_command(prefix) { |path, c| rows << [path, c] unless c.desc.empty? }
780
+ rows << [prefix, sibling.to_h(prefix)] if sibling && !sibling.desc.empty?
781
+ ns.each_command(prefix) { |path, c| rows << [path, c.to_h(path)] unless c.desc.empty? }
741
782
  unless rows.empty?
742
783
  Shell.say ''
743
784
  Shell.say 'Commands:', :yellow
@@ -802,38 +843,30 @@ class Hammer
802
843
  Shell.say Hammer::STARTER_HAMMERFILE
803
844
  end
804
845
 
805
- def print_command_list(klass, prefix = nil, include_builtins: true)
806
- rows = []
807
- # Commands without a `desc` are hidden from listings but still
808
- # dispatchable + `hammer`-callable - useful for private helpers
809
- # invoked from `before` hooks or other commands (e.g. `:env`, `:app`).
810
- klass.each_command(prefix, include_builtins: include_builtins) { |full, c| rows << [full, c] unless c.desc.empty? }
811
- return if rows.empty?
812
-
813
- # group by "section" = everything between the view prefix and the
814
- # leaf name. Bare leaves go in :root.
815
- groups = rows.group_by { |full, _| section_for(full, prefix, klass) }
816
- width = rows.map { |full, _| full.length }.max
817
- first = true
818
-
819
- if (rooted = groups.delete(:root))
820
- Shell.say 'Commands:', :yellow
821
- emit_rows(rooted.sort_by { |full, _| [full.count(':'), full] }, width)
822
- first = false
823
- end
824
-
825
- groups.each do |section, items|
826
- Shell.say unless first
827
- first = false
828
- Shell.say "#{section}:", :yellow
829
- emit_rows(items.sort_by { |full, _| [full.count(':'), full] }, width)
846
+ # Pure rendering off `export_spec` - the same grouped structure
847
+ # `h:json` emits, so the listing and the JSON can never drift.
848
+ # `export_spec` already does the work: drops hidden (no-`desc`)
849
+ # tasks, prunes the `h:` tree unless include_builtins, groups by
850
+ # first namespace segment ("__root" for bare tasks), orders "__root"
851
+ # first, and sorts each group by [depth, name].
852
+ def print_command_list(include_builtins: true)
853
+ groups = export_spec(include_builtins: include_builtins)[:commands]
854
+ return if groups.empty?
855
+
856
+ width = groups.values.flat_map(&:keys).map(&:length).max
857
+ groups.each_with_index do |(section, tasks), i|
858
+ Shell.say unless i.zero?
859
+ Shell.say(section == '__root' ? 'Commands:' : "#{section}:", :yellow)
860
+ emit_rows(tasks.to_a, width)
830
861
  end
831
862
  end
832
863
 
864
+ # `rows` is an array of [full_path, task_meta] - the per-task hashes
865
+ # from `Command#to_h` (also used to render namespace listings).
833
866
  def emit_rows(rows, width)
834
- rows.each do |full, c|
835
- brief = c.alts.empty? ? c.brief : "#{c.brief} (alt: #{c.alts.join(', ')})"
836
- brief = "#{brief} #{Shell.paint('(redefined)', :yellow)}" if c.prev_location
867
+ rows.each do |full, t|
868
+ brief = t[:alts].empty? ? t[:brief] : "#{t[:brief]} (alt: #{t[:alts].join(', ')})"
869
+ brief = "#{brief} #{Shell.paint('(redefined)', :yellow)}" if t[:redefined]
837
870
  Shell.say " #{program_name} #{full.ljust(width)} # #{brief}"
838
871
  end
839
872
  end
@@ -878,6 +911,7 @@ class Hammer
878
911
  Shell.say(stripped.empty? ? '' : " #{stripped}")
879
912
  end unless cmd.desc.empty?
880
913
  Shell.say " alias: #{cmd.alts.join(', ')}" unless cmd.alts.empty?
914
+ Shell.say " cron: #{cmd.cron}" if cmd.cron
881
915
  unless cmd.options.empty?
882
916
  Shell.say ''
883
917
  Shell.say 'Options:', :yellow
@@ -1047,6 +1081,7 @@ class Hammer
1047
1081
  def self.cli(argv = ARGV)
1048
1082
  argv = argv.dup
1049
1083
  force_system = !!argv.delete('--system')
1084
+ launch_gui = !!argv.delete('--gui')
1050
1085
 
1051
1086
  # Shebang invocation: `hammer /path/to/script ...args` (kernel passes
1052
1087
  # the script path as argv[0] for `#!/usr/bin/env hammer` files).
@@ -1060,6 +1095,12 @@ class Hammer
1060
1095
  end
1061
1096
 
1062
1097
  path = force_system ? nil : find_hammerfile(Dir.pwd)
1098
+
1099
+ # `hammer --gui` opens the native macOS runner pointed at this project
1100
+ # (the Hammerfile's dir, or cwd when none was found). The CLI just
1101
+ # launches the bundled app and returns.
1102
+ return launch_gui!(path ? File.dirname(path) : Dir.pwd) if launch_gui
1103
+
1063
1104
  unless path
1064
1105
  # No Hammerfile (or --system) - all built-ins are reachable. Bare
1065
1106
  # `hammer`, `hammer h:recipes`, `hammer h:update`, `hammer h:agents`,
@@ -1188,4 +1229,21 @@ class Hammer
1188
1229
  end
1189
1230
  end
1190
1231
 
1232
+ # Spawn the vendored macOS GUI (gui/Hammer.app), pointed at the project
1233
+ # dir and this hammer binary. Launched directly (not via `open`) so it
1234
+ # inherits the caller's environment - the GUI shells back out to this
1235
+ # same `hammer` for `h:json` and task runs, and that needs the same PATH.
1236
+ def self.launch_gui!(project_dir)
1237
+ bin = File.expand_path('../gui/Hammer.app/Contents/MacOS/HammerGUI', __dir__)
1238
+ unless File.executable?(bin)
1239
+ Shell.print_error "GUI app not found at #{bin}"
1240
+ Shell.say 'build it: ./gui/HammerGUI/build_app.sh', :yellow
1241
+ exit 1
1242
+ end
1243
+ hammer_bin = (File.realpath($PROGRAM_NAME) rescue File.expand_path($PROGRAM_NAME))
1244
+ pid = Process.spawn(bin, '--project', File.expand_path(project_dir), '--hammer', hammer_bin)
1245
+ Process.detach(pid)
1246
+ Shell.say "launched Hammer GUI for #{project_dir} (pid #{pid})", :green
1247
+ end
1248
+
1191
1249
  end
@@ -142,8 +142,8 @@ helpers do
142
142
  if message.empty?
143
143
  run 'git reset --mixed'
144
144
  exit
145
- elsif message.length < 5
146
- say 'Please add better commit message, min length 5 chars', :red
145
+ elsif message.length < 1
146
+ say 'Please add better commit message, min length 1 chars', :red
147
147
  next
148
148
  else
149
149
  bump_version
data/recipes/llm.rb CHANGED
@@ -178,7 +178,7 @@ namespace :prompt do
178
178
  def folders
179
179
  [
180
180
  ['local', File.join(Dir.pwd, 'doc', 'command')],
181
- ['global', File.expand_path('~/dev/skills/command')]
181
+ ['global', File.expand_path('~/dev/ai/command')]
182
182
  ]
183
183
  end
184
184
 
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.13
4
+ version: 0.3.15
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-06-05 00:00:00.000000000 Z
11
+ date: 2026-07-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: minitest
@@ -36,10 +36,15 @@ files:
36
36
  - "./AGENTS.md"
37
37
  - "./README.md"
38
38
  - "./bin/hammer"
39
+ - "./gui/Hammer.app/Contents/Info.plist"
40
+ - "./gui/Hammer.app/Contents/MacOS/HammerGUI"
39
41
  - "./lib/hammer/builder.rb"
40
42
  - "./lib/hammer/builtins.rb"
41
43
  - "./lib/hammer/command.rb"
42
44
  - "./lib/hammer/command_builder.rb"
45
+ - "./lib/hammer/cron.rb"
46
+ - "./lib/hammer/cron_server.rb"
47
+ - "./lib/hammer/cron_web.rb"
43
48
  - "./lib/hammer/dotenv.rb"
44
49
  - "./lib/hammer/loader.rb"
45
50
  - "./lib/hammer/option.rb"