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.
- checksums.yaml +4 -4
- data/.version +1 -1
- data/AGENTS.md +40 -2
- data/README.md +112 -0
- data/bin/hammer +10 -0
- data/gui/Hammer.app/Contents/Info.plist +16 -0
- data/gui/Hammer.app/Contents/MacOS/HammerGUI +0 -0
- data/lib/hammer/builtins.rb +68 -0
- data/lib/hammer/command.rb +35 -1
- data/lib/hammer/command_builder.rb +8 -0
- data/lib/hammer/cron.rb +151 -0
- data/lib/hammer/cron_server.rb +416 -0
- data/lib/hammer/cron_web.rb +304 -0
- data/lib/hammer/option.rb +19 -0
- data/lib/lux-hammer.rb +90 -32
- data/recipes/git-helper.rb +2 -2
- data/recipes/llm.rb +1 -1
- metadata +7 -2
|
@@ -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="/">← all jobs</a></p>
|
|
202
|
+
<h2>#{h(path)}</h2>
|
|
203
|
+
<p>
|
|
204
|
+
<code>#{h(job[:cron])}</code> ·
|
|
205
|
+
#{status_badge(job)} ·
|
|
206
|
+
last run #{h(fmt_time(job[:last_run]))} ·
|
|
207
|
+
next run #{h(fmt_time(job[:next_run]))}
|
|
208
|
+
#{job[:duration] ? "· 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} · #{h(@server.root_dir)} · 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
|
-
|
|
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(
|
|
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
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
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,
|
|
835
|
-
brief =
|
|
836
|
-
brief = "#{brief} #{Shell.paint('(redefined)', :yellow)}" if
|
|
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
|
data/recipes/git-helper.rb
CHANGED
|
@@ -142,8 +142,8 @@ helpers do
|
|
|
142
142
|
if message.empty?
|
|
143
143
|
run 'git reset --mixed'
|
|
144
144
|
exit
|
|
145
|
-
elsif message.length <
|
|
146
|
-
say 'Please add better commit message, min length
|
|
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
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.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-
|
|
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"
|