lux-hammer 0.3.5 → 0.3.7
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 +68 -11
- data/README.md +98 -18
- data/lib/hammer/builtins.rb +143 -55
- data/lib/hammer/command.rb +1 -1
- data/lib/hammer/recipe.rb +2 -2
- data/lib/lux-hammer.rb +146 -93
- data/recipes/llm.rb +434 -0
- metadata +3 -2
data/recipes/llm.rb
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
# desc: personal LLM utility CLI (memory store, prompt-token expander, ...)
|
|
2
|
+
|
|
3
|
+
desc <<~TXT
|
|
4
|
+
llm - personal LLM utility CLI
|
|
5
|
+
|
|
6
|
+
Namespaces:
|
|
7
|
+
memory persistent memory store (backs the Claude Code memory plugin)
|
|
8
|
+
prompt token-prefix prompt expander (UserPromptSubmit hook + CLI)
|
|
9
|
+
TXT
|
|
10
|
+
|
|
11
|
+
require 'fileutils'
|
|
12
|
+
require 'json'
|
|
13
|
+
require 'pathname'
|
|
14
|
+
require 'set'
|
|
15
|
+
|
|
16
|
+
STORE ||= ENV['CLAUDE_MEMORY_STORE'] || File.expand_path('~/dev/skills/memory')
|
|
17
|
+
VALID_TYPES ||= %w[user feedback project reference].freeze
|
|
18
|
+
|
|
19
|
+
FileUtils.mkdir_p(STORE)
|
|
20
|
+
|
|
21
|
+
namespace :memory do
|
|
22
|
+
# Helpers are defined inside the namespace block (class_eval'd on the
|
|
23
|
+
# namespace's anonymous Hammer subclass) so the task procs reach them.
|
|
24
|
+
# Top-level `helpers do` would land on the recipe's root class, which
|
|
25
|
+
# namespace subclasses do not inherit from.
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def memory_path(name)
|
|
29
|
+
File.join(STORE, "#{name}.md")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Minimal frontmatter reader. Handles one level of nesting (good enough for
|
|
33
|
+
# `metadata: { type: ... }`). Returns [meta_hash, body_string].
|
|
34
|
+
def parse_memory(path)
|
|
35
|
+
raw = File.read(path)
|
|
36
|
+
return [{}, raw] unless raw.start_with?("---\n")
|
|
37
|
+
_, fm, body = raw.split(/^---\s*$/m, 3)
|
|
38
|
+
meta = {}
|
|
39
|
+
current_nested = nil
|
|
40
|
+
fm.each_line do |line|
|
|
41
|
+
next if line.strip.empty?
|
|
42
|
+
if (m = line.match(/^([\w-]+):\s*(.*)$/))
|
|
43
|
+
key, val = m[1], m[2].strip
|
|
44
|
+
if val.empty?
|
|
45
|
+
meta[key] = {}
|
|
46
|
+
current_nested = key
|
|
47
|
+
else
|
|
48
|
+
meta[key] = val
|
|
49
|
+
current_nested = nil
|
|
50
|
+
end
|
|
51
|
+
elsif current_nested && (m = line.match(/^\s+([\w-]+):\s*(.*)$/))
|
|
52
|
+
meta[current_nested][m[1]] = m[2].strip
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
[meta, body.to_s.sub(/\A\n+/, '')]
|
|
56
|
+
end
|
|
57
|
+
task :list do
|
|
58
|
+
desc 'List stored memories with type and one-line description'
|
|
59
|
+
example 'llm memory list'
|
|
60
|
+
|
|
61
|
+
proc do
|
|
62
|
+
files = Dir[File.join(STORE, '*.md')].sort
|
|
63
|
+
if files.empty?
|
|
64
|
+
say '(no memories)', :gray
|
|
65
|
+
next
|
|
66
|
+
end
|
|
67
|
+
files.each do |f|
|
|
68
|
+
name = File.basename(f, '.md')
|
|
69
|
+
meta, _ = parse_memory(f)
|
|
70
|
+
type = meta.dig('metadata', 'type') || '?'
|
|
71
|
+
dsc = meta['description'] || 'no description'
|
|
72
|
+
say "- #{name} [#{type}] - #{dsc}"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
task :read do
|
|
78
|
+
desc 'Print the full content of a memory (frontmatter + body)'
|
|
79
|
+
example 'llm memory read user-role'
|
|
80
|
+
|
|
81
|
+
proc do |opts|
|
|
82
|
+
name = opts[:args].first
|
|
83
|
+
error 'usage: llm memory read <name>' unless name
|
|
84
|
+
path = memory_path(name)
|
|
85
|
+
error "memory not found: #{name}" unless File.file?(path)
|
|
86
|
+
print File.read(path)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
task :write do
|
|
91
|
+
desc <<~DESC
|
|
92
|
+
Write or update a memory. The body is read from stdin.
|
|
93
|
+
|
|
94
|
+
Memory types: user, feedback, project, reference.
|
|
95
|
+
DESC
|
|
96
|
+
example %(echo "deep Go expertise, new to React" | llm memory write user-role --type=user --description="user profile")
|
|
97
|
+
opt :type, desc: 'memory type (user|feedback|project|reference)', req: true
|
|
98
|
+
opt :description, desc: 'one-line summary stored in frontmatter'
|
|
99
|
+
|
|
100
|
+
proc do |opts|
|
|
101
|
+
name = opts[:args].first
|
|
102
|
+
error 'usage: llm memory write <name> --type=<type> [--description="..."] < body' unless name
|
|
103
|
+
error "unknown type: #{opts[:type]} (valid: #{VALID_TYPES.join(', ')})" unless VALID_TYPES.include?(opts[:type])
|
|
104
|
+
|
|
105
|
+
body = $stdin.read
|
|
106
|
+
error 'body is empty (pipe content on stdin)' if body.strip.empty?
|
|
107
|
+
|
|
108
|
+
path = memory_path(name)
|
|
109
|
+
File.open(path, 'w') do |io|
|
|
110
|
+
io.puts '---'
|
|
111
|
+
io.puts "name: #{name}"
|
|
112
|
+
io.puts "description: #{opts[:description]}" if opts[:description]
|
|
113
|
+
io.puts 'metadata:'
|
|
114
|
+
io.puts " type: #{opts[:type]}"
|
|
115
|
+
io.puts '---'
|
|
116
|
+
io.puts
|
|
117
|
+
io.puts body.chomp
|
|
118
|
+
end
|
|
119
|
+
say "wrote: #{path}", :green
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
task :delete do
|
|
124
|
+
desc 'Delete a memory by name'
|
|
125
|
+
example 'llm memory delete old-fact'
|
|
126
|
+
|
|
127
|
+
proc do |opts|
|
|
128
|
+
name = opts[:args].first
|
|
129
|
+
error 'usage: llm memory delete <name>' unless name
|
|
130
|
+
path = memory_path(name)
|
|
131
|
+
error "memory not found: #{name}" unless File.file?(path)
|
|
132
|
+
File.delete(path)
|
|
133
|
+
say "deleted: #{name}", :yellow
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
task :search do
|
|
138
|
+
desc 'Search memory bodies for a query string (case-insensitive)'
|
|
139
|
+
example 'llm memory search react'
|
|
140
|
+
|
|
141
|
+
proc do |opts|
|
|
142
|
+
query = opts[:args].first
|
|
143
|
+
error 'usage: llm memory search <query>' unless query
|
|
144
|
+
hits = Dir[File.join(STORE, '*.md')].sort.select do |f|
|
|
145
|
+
File.read(f).downcase.include?(query.downcase)
|
|
146
|
+
end
|
|
147
|
+
if hits.empty?
|
|
148
|
+
say '(no matches)', :gray
|
|
149
|
+
else
|
|
150
|
+
hits.each { |f| say File.basename(f, '.md') }
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
task :path do
|
|
156
|
+
desc 'Print the storage path (where memory files live)'
|
|
157
|
+
proc { say STORE }
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
namespace :prompt do
|
|
162
|
+
TOKEN_PATTERN ||= /[a-z0-9_-]+/.freeze
|
|
163
|
+
TOKEN_LINE_RE ||= /\A(?:\s*:[a-z0-9_-]+)+\s*\z/.freeze
|
|
164
|
+
HOOK_EVENT ||= 'UserPromptSubmit'
|
|
165
|
+
VERBATIM_INSTRUCTION ||= "INSTRUCTION TO ASSISTANT: Do not answer the user's prompt. Print the message below verbatim to the user, preserving every line exactly as written. Do not summarize, truncate, or paraphrase."
|
|
166
|
+
QUESTION_RULE ||= <<~RULE.strip
|
|
167
|
+
rule (applies ONLY to the current user message, not to subsequent turns in this session):
|
|
168
|
+
* answer only
|
|
169
|
+
* do not modify files
|
|
170
|
+
* ask before making changes
|
|
171
|
+
RULE
|
|
172
|
+
|
|
173
|
+
private
|
|
174
|
+
|
|
175
|
+
def folders
|
|
176
|
+
[
|
|
177
|
+
['local', File.join(Dir.pwd, 'doc', 'command')],
|
|
178
|
+
['global', File.expand_path('~/dev/skills/command')]
|
|
179
|
+
]
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def tokens_in(input)
|
|
183
|
+
out = []
|
|
184
|
+
scanner = input.to_s.strip
|
|
185
|
+
while (m = scanner.match(/\A(?::(?<pre>#{TOKEN_PATTERN})|(?<post>#{TOKEN_PATTERN}):)(?=\s|$)/))
|
|
186
|
+
out << (m[:pre] || m[:post])
|
|
187
|
+
scanner = scanner[m[0].length..].to_s.lstrip
|
|
188
|
+
end
|
|
189
|
+
out.uniq
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def find_command_path(token)
|
|
193
|
+
folders.map { |_label, folder| File.join(folder, "#{token}.md") }.find { |c| File.file?(c) }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def display_path(path)
|
|
197
|
+
Pathname.new(path).cleanpath.to_s.sub(%r{\A#{Regexp.escape(Dir.home)}/}, '~/')
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def first_line_description(path)
|
|
201
|
+
File.foreach(path).first.to_s.strip.sub(/\A#+\s*/, '')
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def grouped_listing
|
|
205
|
+
ordered = folders.sort_by { |label, _| label == 'global' ? 0 : 1 }
|
|
206
|
+
ordered.map do |label, folder|
|
|
207
|
+
toks = Dir.glob(File.join(folder, '*.md')).map { |p| File.basename(p, '.md') }.sort
|
|
208
|
+
items = toks.empty? ? '(none)' : toks.map { |t| ":#{t}" }.join(', ')
|
|
209
|
+
"Available #{label} in #{display_path(folder)} -> #{items}"
|
|
210
|
+
end.join("\n")
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def help_listing
|
|
214
|
+
ordered = folders.sort_by { |label, _| label == 'global' ? 0 : 1 }
|
|
215
|
+
sections = ordered.map do |label, folder|
|
|
216
|
+
files = Dir.glob(File.join(folder, '*.md')).sort
|
|
217
|
+
header = "#{label} (#{display_path(folder)}):"
|
|
218
|
+
if files.empty?
|
|
219
|
+
"#{header}\n (none)"
|
|
220
|
+
else
|
|
221
|
+
width = files.map { |p| File.basename(p, '.md').length }.max
|
|
222
|
+
entries = files.map do |path|
|
|
223
|
+
name = File.basename(path, '.md')
|
|
224
|
+
dscr = first_line_description(path)
|
|
225
|
+
dscr = '(no description)' if dscr.empty?
|
|
226
|
+
" :#{name.ljust(width)} #{dscr}"
|
|
227
|
+
end
|
|
228
|
+
"#{header}\n#{entries.join("\n")}"
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
"Available commands:\n\n#{sections.join("\n\n")}"
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def agents_listing
|
|
235
|
+
cwd = Pathname.new(Dir.pwd)
|
|
236
|
+
home = Pathname.new(Dir.home)
|
|
237
|
+
|
|
238
|
+
dirs = [home]
|
|
239
|
+
if cwd != home && cwd.to_s.start_with?("#{home}/")
|
|
240
|
+
current = home
|
|
241
|
+
cwd.relative_path_from(home).to_s.split('/').each do |part|
|
|
242
|
+
current += part
|
|
243
|
+
dirs << current
|
|
244
|
+
end
|
|
245
|
+
elsif cwd != home
|
|
246
|
+
dirs << cwd
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
found = dirs.filter_map { |d| (d + 'AGENTS.md').to_s if (d + 'AGENTS.md').file? }
|
|
250
|
+
|
|
251
|
+
if found.empty?
|
|
252
|
+
msg = "No AGENTS.md files found from #{display_path(home.to_s)} to #{display_path(cwd.to_s)}"
|
|
253
|
+
warn msg
|
|
254
|
+
return msg
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
content = found.each_with_index.map do |path, i|
|
|
258
|
+
lines = []
|
|
259
|
+
lines << '---' if i.positive?
|
|
260
|
+
lines << "Loaded #{display_path(path)}"
|
|
261
|
+
lines << ''
|
|
262
|
+
lines << File.read(path)
|
|
263
|
+
lines.join("\n")
|
|
264
|
+
end.join("\n")
|
|
265
|
+
|
|
266
|
+
approx_tokens = (content.bytesize / 4.0).round
|
|
267
|
+
label = found.size == 1 ? 'file' : 'files'
|
|
268
|
+
summary = "Loaded #{found.size} AGENTS.md #{label} (~#{approx_tokens} tokens): #{found.map { |p| display_path(p) }.join(', ')}"
|
|
269
|
+
warn summary
|
|
270
|
+
|
|
271
|
+
instruction = "INSTRUCTION TO ASSISTANT: The AGENTS.md files below have been loaded into your context - apply them to all subsequent work in this session. If the user's current message contains no other request, reply with exactly this one line and nothing else: \"#{summary}\"."
|
|
272
|
+
"#{instruction}\n\n#{content}"
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def transform_strip_title(content)
|
|
276
|
+
return content unless content.lstrip.start_with?('#')
|
|
277
|
+
content.sub(/\A\s*#[^\n]*\n?/, '').lstrip
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def transform_expand_command_prefix(content, seen)
|
|
281
|
+
prefix_tokens = []
|
|
282
|
+
remaining = content
|
|
283
|
+
|
|
284
|
+
loop do
|
|
285
|
+
line, rest = remaining.split("\n", 2)
|
|
286
|
+
break unless line
|
|
287
|
+
stripped = line.strip
|
|
288
|
+
|
|
289
|
+
if stripped.empty?
|
|
290
|
+
break if prefix_tokens.empty?
|
|
291
|
+
remaining = rest.to_s
|
|
292
|
+
next
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
break unless stripped =~ TOKEN_LINE_RE
|
|
296
|
+
prefix_tokens.concat(stripped.scan(/:(#{TOKEN_PATTERN})/).flatten)
|
|
297
|
+
remaining = rest.to_s
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
return content if prefix_tokens.empty?
|
|
301
|
+
|
|
302
|
+
expanded = prefix_tokens.map { |t| load_command_content(t, seen) }.join("\n\n")
|
|
303
|
+
remaining = remaining.lstrip
|
|
304
|
+
remaining.empty? ? expanded : "#{expanded}\n\n#{remaining}"
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def transform_expand_file_includes(content)
|
|
308
|
+
content.gsub(/^[ \t]*@(\S+)[ \t]*$/) do
|
|
309
|
+
raw = Regexp.last_match(1)
|
|
310
|
+
expanded = File.expand_path(raw)
|
|
311
|
+
error "missing include #{raw}" unless File.file?(expanded)
|
|
312
|
+
File.read(expanded)
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def load_command_content(token, seen)
|
|
317
|
+
error "circular include of :#{token}" if seen.include?(token)
|
|
318
|
+
path = find_command_path(token)
|
|
319
|
+
error %(custom token ":#{token}" not found.\n\n#{grouped_listing}) unless path
|
|
320
|
+
|
|
321
|
+
child_seen = seen + [token]
|
|
322
|
+
content = File.read(path)
|
|
323
|
+
content = transform_strip_title(content)
|
|
324
|
+
content = transform_expand_command_prefix(content, child_seen)
|
|
325
|
+
content = transform_expand_file_includes(content)
|
|
326
|
+
content
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def verbatim_response(body, fail_open:)
|
|
330
|
+
return body unless fail_open
|
|
331
|
+
"#{VERBATIM_INSTRUCTION}\n\n#{body}"
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def append_question_rule(input, context)
|
|
335
|
+
return context unless input.to_s.strip.end_with?('?')
|
|
336
|
+
context.to_s.empty? ? QUESTION_RULE : "#{context}\n\n---\n#{QUESTION_RULE}"
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def build_context(input, fail_open: false)
|
|
340
|
+
toks = tokens_in(input)
|
|
341
|
+
return '' if toks.empty?
|
|
342
|
+
return verbatim_response(help_listing, fail_open: fail_open) if toks.include?('help')
|
|
343
|
+
return agents_listing if toks.include?('agents')
|
|
344
|
+
|
|
345
|
+
seen = Set.new
|
|
346
|
+
loaded = toks.map do |token|
|
|
347
|
+
path = find_command_path(token)
|
|
348
|
+
error %(custom token ":#{token}" not found.\n\n#{grouped_listing}) unless path
|
|
349
|
+
[token, Pathname.new(path).cleanpath.to_s, load_command_content(token, seen)]
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
loaded.each_with_index.map do |(_token, path, content), index|
|
|
353
|
+
lines = []
|
|
354
|
+
lines << '---' if index.positive?
|
|
355
|
+
lines << "Loaded #{display_path(path)}"
|
|
356
|
+
lines << ''
|
|
357
|
+
lines << (content.to_s.empty? ? '(empty custom command file)' : content)
|
|
358
|
+
lines.join("\n")
|
|
359
|
+
end.join("\n")
|
|
360
|
+
rescue Hammer::Error => e
|
|
361
|
+
raise unless fail_open
|
|
362
|
+
verbatim_response("ERROR: #{e.message}", fail_open: true)
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def load_context(input, fail_open: false)
|
|
366
|
+
append_question_rule(input, build_context(input, fail_open: fail_open))
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def hook_json(context)
|
|
370
|
+
context = context.to_s.strip
|
|
371
|
+
return { continue: true } if context.empty?
|
|
372
|
+
{
|
|
373
|
+
continue: true,
|
|
374
|
+
hookSpecificOutput: {
|
|
375
|
+
hookEventName: HOOK_EVENT,
|
|
376
|
+
additionalContext: "<llm_command_context>\n#{context}\n</llm_command_context>"
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
task :list do
|
|
382
|
+
desc 'List available prompt commands, one line per folder'
|
|
383
|
+
proc { say grouped_listing }
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
task :help do
|
|
387
|
+
desc 'List available prompt commands with their first-line descriptions'
|
|
388
|
+
proc { say help_listing }
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
task :agents do
|
|
392
|
+
desc 'Load all AGENTS.md from home down to cwd and print the combined content'
|
|
393
|
+
proc { say agents_listing }
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
task :expand do
|
|
397
|
+
desc 'Expand prompt token(s) and print the resulting context'
|
|
398
|
+
example 'llm prompt:expand :foo :bar'
|
|
399
|
+
example 'llm prompt:expand foo:'
|
|
400
|
+
|
|
401
|
+
proc do |opts|
|
|
402
|
+
input = opts[:args].join(' ')
|
|
403
|
+
error 'usage: llm prompt:expand :token [:token ...]' if input.empty?
|
|
404
|
+
out = load_context(input)
|
|
405
|
+
say out unless out.empty?
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
task :hook do
|
|
410
|
+
desc <<~D
|
|
411
|
+
UserPromptSubmit hook entry. Reads {"prompt": ...} JSON on stdin,
|
|
412
|
+
expands any token prefix, and emits hookSpecificOutput JSON on stdout.
|
|
413
|
+
|
|
414
|
+
Pair with HAMMER_QUIET=1 in the hook command so the runtime banner
|
|
415
|
+
doesn't pollute stdout.
|
|
416
|
+
D
|
|
417
|
+
opt :claude, type: :boolean, default: false, desc: 'Claude Code hook mode'
|
|
418
|
+
opt :codex, type: :boolean, default: false, desc: 'Codex hook mode'
|
|
419
|
+
|
|
420
|
+
proc do |opts|
|
|
421
|
+
error '--claude or --codex required' unless opts[:claude] || opts[:codex]
|
|
422
|
+
|
|
423
|
+
raw = $stdin.read
|
|
424
|
+
prompt = begin
|
|
425
|
+
JSON.parse(raw).fetch('prompt', raw)
|
|
426
|
+
rescue JSON::ParserError
|
|
427
|
+
raw
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
context = load_context(prompt.to_s, fail_open: true)
|
|
431
|
+
puts JSON.generate(hook_json(context))
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
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.7
|
|
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-05-
|
|
11
|
+
date: 2026-05-25 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: minitest
|
|
@@ -48,6 +48,7 @@ files:
|
|
|
48
48
|
- "./lib/hammer/shell.rb"
|
|
49
49
|
- "./lib/lux-hammer.rb"
|
|
50
50
|
- "./recipes/git-helper.rb"
|
|
51
|
+
- "./recipes/llm.rb"
|
|
51
52
|
- "./recipes/srt.rb"
|
|
52
53
|
- bin/hammer
|
|
53
54
|
homepage: https://github.com/dux/hammer
|