lux-hammer 0.3.6 → 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/recipe.rb +2 -2
- data/lib/lux-hammer.rb +116 -93
- data/recipes/llm.rb +434 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7e19b144891467fd0209c27265352a153f340820d14384d8deacb46e8ef58532
|
|
4
|
+
data.tar.gz: 279c7ac83d6f597d28e42b799e5cfdc917b0ce10f88d2950d9f0fe562ed674de
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e9baea7eb17ad8b98ec3f7778431c603a03283e029a95e9140f83026aa88f587e01a4e87bf7dc09b6538a95f463f067293daf147df02e3454f161d737c9e6bad
|
|
7
|
+
data.tar.gz: 4b41c8875111734dfbe6402b97eaa03be209081f3086096cde94343eacbb1c5fd18d2933575ad1818ca08e6ffae363fd695cb107d5ddb7068610580bd04b7289
|
data/.version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.3.
|
|
1
|
+
0.3.7
|
data/AGENTS.md
CHANGED
|
@@ -45,7 +45,7 @@ lib/hammer/loader.rb # `*_hammer.rb` fragment loader (auto/glob/file)
|
|
|
45
45
|
lib/hammer/builder.rb # Block-DSL context (Hammerfile / Hammer.run)
|
|
46
46
|
lib/hammer/command_builder.rb # `task :name do ... end` context
|
|
47
47
|
lib/hammer/recipe.rb # Recipe discovery (gem + user dir, desc, stub)
|
|
48
|
-
lib/hammer/builtins.rb #
|
|
48
|
+
lib/hammer/builtins.rb # Built-in tasks (recipes, update, agents, version, init, ...)
|
|
49
49
|
recipes/ # Bundled recipes (each `<name>.rb` -> a bin via stub)
|
|
50
50
|
test/dsl_test.rb # DSL surface, dispatch, help formatting
|
|
51
51
|
test/load_test.rb # `load` / `*_hammer.rb` fragment loader
|
|
@@ -143,11 +143,20 @@ Runtime cross-invocation:
|
|
|
143
143
|
(`bin/hammer`).
|
|
144
144
|
* `Hammer.cli(argv = ARGV)` - internal entry for `bin/hammer` only:
|
|
145
145
|
walks up from `Dir.pwd` for a `Hammerfile`, chdirs into its dir,
|
|
146
|
-
errors if none found anywhere up the tree
|
|
147
|
-
|
|
148
|
-
`
|
|
149
|
-
|
|
150
|
-
|
|
146
|
+
errors if none found anywhere up the tree (unless the invocation
|
|
147
|
+
routes to a built-in - see `dispatches_to_builtin?` /
|
|
148
|
+
`looks_like_builtin?`, true for bare invocation / leading flag /
|
|
149
|
+
explicit help / a built-in task name). After evaluating the
|
|
150
|
+
Hammerfile, registers the always-on core built-ins (`:default`,
|
|
151
|
+
`:help`, `:update`, `:agents`, `:version`) via
|
|
152
|
+
`Hammer::Builtins.register_core` - each guarded by
|
|
153
|
+
`unless commands.key?(...)` so a user-defined task wins silently.
|
|
154
|
+
No-project-only built-ins (`:recipes`, `:init`) are NOT registered
|
|
155
|
+
when a Hammerfile loaded - reach them with `--system`. The
|
|
156
|
+
`--system` flag is peeled off argv at the top and forces the
|
|
157
|
+
no-Hammerfile branch. Not part of the user-facing surface - don't
|
|
158
|
+
recommend it in examples; `Hammer.run` is what library users should
|
|
159
|
+
reach for.
|
|
151
160
|
* `Hammer.recipe(name, argv = ARGV)` - entry for recipe stubs in PATH.
|
|
152
161
|
Loads `<gem>/recipes/<name>.rb` (or its user-dir override), runs as
|
|
153
162
|
a standalone CLI with `program_name = name`. No Hammerfile lookup,
|
|
@@ -186,7 +195,9 @@ explicit ADR-level discussion. Keys:
|
|
|
186
195
|
actually picked when fuzzy matching kicks in. Only options that
|
|
187
196
|
differ from their default are listed; booleans render as `--flag`
|
|
188
197
|
/ `--no-flag`. Help paths (`-h`, bare namespace) short-circuit
|
|
189
|
-
before the banner.
|
|
198
|
+
before the banner. Set `HAMMER_QUIET=1` to suppress the banner
|
|
199
|
+
globally - useful when a task writes machine-readable output to
|
|
200
|
+
stdout (e.g. a JSON-emitting Claude Code / Codex hook).
|
|
190
201
|
* There is **no per-level dispatch**. A namespace is a container, not a
|
|
191
202
|
CLI of its own. Do not reintroduce `subclass.start(remaining_argv)`.
|
|
192
203
|
* `start(argv)` is a two-step pipeline: `split_chain(argv)` (private)
|
|
@@ -206,11 +217,57 @@ explicit ADR-level discussion. Keys:
|
|
|
206
217
|
is the whole API. `Hammer.cli` warms the cache before chdir-ing into
|
|
207
218
|
the Hammerfile's directory so the resolved name stays relative to the
|
|
208
219
|
cwd the user invoked from.
|
|
209
|
-
* `hammer` (no args)
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
220
|
+
* `hammer` (no args) routes to the `:default` built-in task, which
|
|
221
|
+
falls through to `print_help(extended: false)` - the brief command
|
|
222
|
+
listing with a top gray `lux-hammer VERSION - <homepage>` banner.
|
|
223
|
+
* `hammer -h` / `--help` / `help [TARGET]` routes to the `:help`
|
|
224
|
+
built-in task, which calls `print_help(target, extended: true)` -
|
|
225
|
+
adds global flags (rendered from `:default`'s declared opts),
|
|
226
|
+
`Recipes:` section (hammer binary only), a small Hammerfile example,
|
|
227
|
+
and the footer link.
|
|
213
228
|
* `hammer COMMAND -h` / `--help` prints per-command help (reserved on every command).
|
|
229
|
+
|
|
230
|
+
## Built-in tasks (user-overridable)
|
|
231
|
+
|
|
232
|
+
`Hammer::Builtins` defines two registration entry points; both register
|
|
233
|
+
**after** Hammerfile evaluation and skip each task when the user
|
|
234
|
+
already defined it (`unless klass.commands.key?(name)` - no
|
|
235
|
+
redefinition warning).
|
|
236
|
+
|
|
237
|
+
* `register_core(klass)` - always called. Registers `:default`,
|
|
238
|
+
`:help`, `:update`, `:agents`, `:version`. These coexist with
|
|
239
|
+
Hammerfile tasks.
|
|
240
|
+
* `register_no_project(klass)` - called only on the no-Hammerfile
|
|
241
|
+
branch of `Hammer.cli` (which `--system` also routes through).
|
|
242
|
+
Registers `:recipes` and `:init` - tasks that would collide too
|
|
243
|
+
easily with user tasks inside a project.
|
|
244
|
+
|
|
245
|
+
No reserved namespace. Names like `:self`, `:recipes`, `:update` can
|
|
246
|
+
all be defined freely in a Hammerfile; the built-ins yield.
|
|
247
|
+
|
|
248
|
+
Task contracts:
|
|
249
|
+
|
|
250
|
+
* `:default` - hidden (no desc). Just prints `self.class.root.print_help`
|
|
251
|
+
(brief). Fires for bare `hammer` and leading-flag invocations where
|
|
252
|
+
the first token starts with `-` and isn't `-h` / `--help` (see
|
|
253
|
+
`Hammer.dispatch`). User overrides typically declare flag opts and
|
|
254
|
+
fall through to `hammer :help` when none matched.
|
|
255
|
+
* `:help` - has a desc, shows in listings. Calls
|
|
256
|
+
`self.class.root.print_help(opts[:args].first, extended: true)`.
|
|
257
|
+
Fires for `help` / `-h` / `--help` at the top level.
|
|
258
|
+
* `:update` / `:agents` / `:version` - thin wrappers around
|
|
259
|
+
`Hammer.self_update` / `Hammer.print_ai_help` / `puts Hammer::VERSION`.
|
|
260
|
+
* `:recipes` - all recipe-management actions on one task, picked via
|
|
261
|
+
boolean opts: `--install [NAME [TARGET]]`, `--show NAME`,
|
|
262
|
+
`--path NAME`, `--edit NAME`, `--run NAME [ARGS]` (use `--` to
|
|
263
|
+
forward flags through). Bare invocation lists.
|
|
264
|
+
* `:init` - writes `Hammer::STARTER_HAMMERFILE` to `./Hammerfile`;
|
|
265
|
+
refuses if one exists.
|
|
266
|
+
|
|
267
|
+
Both `:default` and `:help` are invoked via `run_command(cmd, argv,
|
|
268
|
+
full: name, quiet: true)` - the gray `> prog cmd ...` banner is
|
|
269
|
+
suppressed because the user didn't type `hammer default` /
|
|
270
|
+
`hammer help` literally.
|
|
214
271
|
* Commands listed flat with colon paths, grouped by top-level namespace.
|
|
215
272
|
* Bare namespace (`hammer db`) prints the same listing scoped to that
|
|
216
273
|
namespace.
|
data/README.md
CHANGED
|
@@ -73,7 +73,7 @@ Clones into `~/.local/share/lux-hammer` (override with `LUX_HAMMER_DIR=`),
|
|
|
73
73
|
builds the gem, and installs it. Re-run any time, or use:
|
|
74
74
|
|
|
75
75
|
```sh
|
|
76
|
-
hammer
|
|
76
|
+
hammer update # git pull main + rebuild + reinstall
|
|
77
77
|
```
|
|
78
78
|
|
|
79
79
|
## Quick start
|
|
@@ -483,10 +483,6 @@ hammer : # trailing colon at root: full help for every command
|
|
|
483
483
|
Namespaces nest to any depth. There is no per-level dispatch - the root
|
|
484
484
|
parses the whole colon path and walks the namespace tree.
|
|
485
485
|
|
|
486
|
-
The `self:` namespace is reserved for hammer's own built-in commands
|
|
487
|
-
(see [Recipes](#recipes-shareable-mini-clis-shipped-with-hammer)
|
|
488
|
-
below). Defining `namespace :self` in a Hammerfile raises an error.
|
|
489
|
-
|
|
490
486
|
## Pre-hooks (`before`)
|
|
491
487
|
|
|
492
488
|
A `before do ... end` block at the root scope or inside a `namespace`
|
|
@@ -541,6 +537,78 @@ end
|
|
|
541
537
|
`hammer` and `hammer db` won't list `env`, but `hammer env`,
|
|
542
538
|
`hammer :env` from another proc, and `before { hammer :env }` all work.
|
|
543
539
|
|
|
540
|
+
## Built-in tasks (all overridable)
|
|
541
|
+
|
|
542
|
+
The `hammer` binary auto-registers a small set of built-in tasks at
|
|
543
|
+
the root of your CLI. Each one is guarded by `unless commands.key?` -
|
|
544
|
+
defining your own `task :name` in a Hammerfile silently replaces the
|
|
545
|
+
built-in.
|
|
546
|
+
|
|
547
|
+
Always available (with or without a Hammerfile):
|
|
548
|
+
|
|
549
|
+
* `:default` - fires on bare `hammer` and on leading-flag invocations.
|
|
550
|
+
Hidden from listings. Just prints the brief help by default; override
|
|
551
|
+
to wire up your own global flags.
|
|
552
|
+
* `:help` - `hammer help [TARGET]`, `hammer -h`, `hammer --help`. Prints
|
|
553
|
+
the extended help view; `TARGET` accepts a command path or a `ns:`
|
|
554
|
+
prefix.
|
|
555
|
+
* `:update` - rebuild + reinstall lux-hammer from main.
|
|
556
|
+
* `:agents` - dump AGENTS.md (AI-friendly Hammerfile authoring docs).
|
|
557
|
+
* `:version` - print the lux-hammer version.
|
|
558
|
+
|
|
559
|
+
Only when no Hammerfile is loaded (or `--system` is passed - see below):
|
|
560
|
+
|
|
561
|
+
* `:recipes` - list / install / show / edit recipes.
|
|
562
|
+
* `:init` - write a starter Hammerfile in cwd (refuses if one exists).
|
|
563
|
+
|
|
564
|
+
These two are skipped inside a project so they don't shadow user tasks.
|
|
565
|
+
To reach them from inside a project, pass `--system`:
|
|
566
|
+
|
|
567
|
+
```sh
|
|
568
|
+
hammer --system recipes # list recipes from anywhere
|
|
569
|
+
hammer --system recipes --install srt ~/bin/srt
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
`--system` forces the no-Hammerfile branch - the Hammerfile in the
|
|
573
|
+
current tree (if any) isn't loaded for that invocation.
|
|
574
|
+
|
|
575
|
+
Customize bare `hammer` by replacing `:default`:
|
|
576
|
+
|
|
577
|
+
```ruby
|
|
578
|
+
# Hammerfile
|
|
579
|
+
|
|
580
|
+
task :default do
|
|
581
|
+
opt :version, type: :boolean, alias: :v
|
|
582
|
+
opt :status, type: :boolean, alias: :s
|
|
583
|
+
|
|
584
|
+
proc do |opts|
|
|
585
|
+
if opts[:version]
|
|
586
|
+
say "myapp #{MyApp::VERSION}"
|
|
587
|
+
elsif opts[:status]
|
|
588
|
+
sh 'bin/myapp status'
|
|
589
|
+
else
|
|
590
|
+
hammer :help # fall through to help
|
|
591
|
+
end
|
|
592
|
+
end
|
|
593
|
+
end
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
Or replace `:help` to add a banner:
|
|
597
|
+
|
|
598
|
+
```ruby
|
|
599
|
+
task :help do
|
|
600
|
+
desc 'Show help (with a banner)'
|
|
601
|
+
proc do |opts|
|
|
602
|
+
say.cyan "MyApp #{MyApp::VERSION} - https://internal/docs"
|
|
603
|
+
say ''
|
|
604
|
+
self.class.root.print_help(opts[:args].first, extended: true)
|
|
605
|
+
end
|
|
606
|
+
end
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
`-h` / `--help` stay reserved on every command - you can't shadow them
|
|
610
|
+
with an `opt`.
|
|
611
|
+
|
|
544
612
|
## Prereqs (`needs`)
|
|
545
613
|
|
|
546
614
|
Declare commands that must run before this one (Rake-style task deps):
|
|
@@ -1003,19 +1071,29 @@ A **recipe** is a standalone Hammerfile-style script bundled inside the
|
|
|
1003
1071
|
top-level binary in your `PATH` - so `srt` becomes a real command, not
|
|
1004
1072
|
`hammer srt:shift`.
|
|
1005
1073
|
|
|
1074
|
+
Recipe management lives under the `recipes` task. From inside a
|
|
1075
|
+
project the task isn't registered (so it can't shadow user tasks);
|
|
1076
|
+
pass `--system` to reach it from anywhere.
|
|
1077
|
+
|
|
1006
1078
|
Listing what's available:
|
|
1007
1079
|
|
|
1008
1080
|
```sh
|
|
1009
|
-
$ hammer
|
|
1081
|
+
$ hammer recipes # from any non-project dir
|
|
1082
|
+
$ hammer --system recipes # from inside a project
|
|
1010
1083
|
gem:
|
|
1011
1084
|
srt # Subtitle (.srt) toolkit - shift timestamps, show stats
|
|
1012
|
-
[install: hammer
|
|
1085
|
+
[install: hammer recipes --install srt]
|
|
1086
|
+
llm # personal LLM utility CLI (memory store, prompt-token expander, ...)
|
|
1087
|
+
[install: hammer recipes --install llm]
|
|
1013
1088
|
```
|
|
1014
1089
|
|
|
1015
|
-
Installing one
|
|
1090
|
+
Installing one. With no TARGET, the stub is printed to stdout (you
|
|
1091
|
+
redirect it yourself); with a TARGET path, lux-hammer writes the file
|
|
1092
|
+
and chmods +x in one step:
|
|
1016
1093
|
|
|
1017
1094
|
```sh
|
|
1018
|
-
$ hammer
|
|
1095
|
+
$ hammer recipes --install srt ~/bin/srt # write + chmod
|
|
1096
|
+
$ hammer recipes --install srt > ~/bin/srt && chmod +x ~/bin/srt
|
|
1019
1097
|
$ srt --help
|
|
1020
1098
|
Usage: srt COMMAND [ARGS]
|
|
1021
1099
|
|
|
@@ -1032,8 +1110,8 @@ so the recipe always runs the version currently in the gem.
|
|
|
1032
1110
|
### Authoring your own
|
|
1033
1111
|
|
|
1034
1112
|
Drop a plain `.rb` file in `~/.config/hammer/recipes/`. The first
|
|
1035
|
-
`# desc: ...` comment is what shows in `hammer
|
|
1036
|
-
|
|
1113
|
+
`# desc: ...` comment is what shows in `hammer recipes`. The file body
|
|
1114
|
+
uses the same DSL as a Hammerfile - `task`, `namespace`, `before`,
|
|
1037
1115
|
`load`. Example `~/.config/hammer/recipes/json.rb`:
|
|
1038
1116
|
|
|
1039
1117
|
```ruby
|
|
@@ -1052,20 +1130,22 @@ end
|
|
|
1052
1130
|
Install it the same way:
|
|
1053
1131
|
|
|
1054
1132
|
```sh
|
|
1055
|
-
$ hammer
|
|
1133
|
+
$ hammer recipes --install json ~/bin/json
|
|
1056
1134
|
```
|
|
1057
1135
|
|
|
1058
1136
|
User-dir recipes override gem recipes with the same name, so you can
|
|
1059
1137
|
fork without forking.
|
|
1060
1138
|
|
|
1061
|
-
### Other `
|
|
1139
|
+
### Other `recipes` actions
|
|
1062
1140
|
|
|
1063
1141
|
```sh
|
|
1064
|
-
hammer
|
|
1065
|
-
hammer
|
|
1066
|
-
hammer
|
|
1067
|
-
hammer
|
|
1068
|
-
hammer
|
|
1142
|
+
hammer recipes # list all
|
|
1143
|
+
hammer recipes --install # interactive picker, then prints stub
|
|
1144
|
+
hammer recipes --show NAME # cat the recipe source
|
|
1145
|
+
hammer recipes --path NAME # absolute path
|
|
1146
|
+
hammer recipes --edit NAME # open in $EDITOR (copies gem -> user dir first)
|
|
1147
|
+
hammer recipes --run NAME [ARGS] # run without installing its bin
|
|
1148
|
+
hammer recipes --run NAME -- --help # `--` forwards flags to the recipe
|
|
1069
1149
|
```
|
|
1070
1150
|
|
|
1071
1151
|
## Programmatic use
|
data/lib/hammer/builtins.rb
CHANGED
|
@@ -1,78 +1,166 @@
|
|
|
1
1
|
class Hammer
|
|
2
|
-
#
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
2
|
+
# Built-in tasks of the `hammer` binary - all live at the root level
|
|
3
|
+
# (no reserved namespace). Two registration entry points:
|
|
4
|
+
#
|
|
5
|
+
# * register_core - tasks always available (subject to user override
|
|
6
|
+
# via the `unless commands.key?(...)` guard): :default, :help,
|
|
7
|
+
# :update, :agents, :version. These coexist with Hammerfile tasks.
|
|
8
|
+
#
|
|
9
|
+
# * register_no_project - tasks meaningful only when no Hammerfile is
|
|
10
|
+
# loaded (or `--system` was passed): :recipes, :init. These would
|
|
11
|
+
# collide too easily with user tasks if always registered, so the
|
|
12
|
+
# Hammerfile branch skips them.
|
|
7
13
|
module Builtins
|
|
8
14
|
module_function
|
|
9
15
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
klass.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
def register_core(klass)
|
|
17
|
+
register_help(klass) unless klass.commands.key?('help')
|
|
18
|
+
register_default(klass) unless klass.commands.key?('default')
|
|
19
|
+
register_update(klass) unless klass.commands.key?('update')
|
|
20
|
+
register_agents(klass) unless klass.commands.key?('agents')
|
|
21
|
+
register_version(klass) unless klass.commands.key?('version')
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def register_no_project(klass)
|
|
25
|
+
register_recipes(klass) unless klass.commands.key?('recipes')
|
|
26
|
+
register_init(klass) unless klass.commands.key?('init')
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def register_help(klass)
|
|
30
|
+
klass.class_eval do
|
|
31
|
+
task :help do
|
|
32
|
+
desc <<~TXT
|
|
33
|
+
Show help. Optional TARGET = command name or namespace (`ns:`).
|
|
34
|
+
|
|
35
|
+
Without TARGET prints the extended top-level help (commands,
|
|
36
|
+
recipes, global flags, examples). With a command path prints
|
|
37
|
+
per-command help; with a namespace prefix prints that
|
|
38
|
+
namespace's command listing.
|
|
39
|
+
TXT
|
|
40
|
+
example 'help'
|
|
41
|
+
example 'help build'
|
|
42
|
+
example 'help db:'
|
|
43
|
+
proc do |opts|
|
|
44
|
+
self.class.root.print_help(opts[:args].first, extended: true)
|
|
45
|
+
end
|
|
18
46
|
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# `:default` fires on bare `hammer` and on leading-flag invocations
|
|
51
|
+
# (other than -h/--help). Hidden from listings (no desc). All it
|
|
52
|
+
# does now is print help - the old flag opts (--update, --ai, ...)
|
|
53
|
+
# moved to dedicated tasks (:update, :agents, ...).
|
|
54
|
+
def register_default(klass)
|
|
55
|
+
klass.class_eval do
|
|
56
|
+
task :default do
|
|
57
|
+
proc { self.class.root.print_help }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
19
61
|
|
|
62
|
+
def register_update(klass)
|
|
63
|
+
klass.class_eval do
|
|
20
64
|
task :update do
|
|
21
|
-
desc '
|
|
65
|
+
desc 'Rebuild + reinstall lux-hammer from main'
|
|
22
66
|
proc { Hammer.self_update }
|
|
23
67
|
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
24
70
|
|
|
25
|
-
|
|
71
|
+
def register_agents(klass)
|
|
72
|
+
klass.class_eval do
|
|
73
|
+
task :agents do
|
|
74
|
+
desc 'Print AGENTS.md (Hammerfile authoring docs for AI assistants)'
|
|
75
|
+
proc { Hammer.print_ai_help }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def register_version(klass)
|
|
81
|
+
klass.class_eval do
|
|
82
|
+
task :version do
|
|
83
|
+
desc 'Print lux-hammer version'
|
|
84
|
+
proc { puts Hammer::VERSION }
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def register_init(klass)
|
|
90
|
+
klass.class_eval do
|
|
91
|
+
task :init do
|
|
92
|
+
desc 'Write a starter Hammerfile in the current directory'
|
|
93
|
+
proc { Hammer::Builtins.write_starter_hammerfile }
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# `:recipes` rolls all recipe-management actions into one task. Bare
|
|
99
|
+
# invocation lists; opts pick the action and positional args carry
|
|
100
|
+
# the recipe name (and optional target path for --install). Run via
|
|
101
|
+
# `--run NAME [ARGS]` - use `--` to forward flags to the recipe
|
|
102
|
+
# itself (e.g. `hammer recipes --run srt -- --help`).
|
|
103
|
+
def register_recipes(klass)
|
|
104
|
+
klass.class_eval do
|
|
105
|
+
task :recipes do
|
|
26
106
|
desc <<~TXT
|
|
27
|
-
Manage recipes
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
install [NAME] [PATH] install stub. No PATH: print to stdout. With PATH: write + chmod +x.
|
|
31
|
-
show NAME cat recipe source
|
|
32
|
-
path NAME print recipe abs path
|
|
33
|
-
edit NAME open recipe in $EDITOR
|
|
34
|
-
run NAME [ARGS] run a recipe without installing its bin
|
|
107
|
+
Manage recipes - the standalone Hammerfile-style scripts
|
|
108
|
+
bundled with the gem (and under ~/.config/hammer/recipes).
|
|
109
|
+
Bare invocation lists; flags pick the action.
|
|
35
110
|
TXT
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
example '
|
|
111
|
+
opt :install, type: :boolean, desc: 'install recipe stub (picker if no NAME). With TARGET path: write + chmod.'
|
|
112
|
+
opt :show, type: :boolean, desc: 'cat recipe source'
|
|
113
|
+
opt :path, type: :boolean, desc: 'print recipe abs path'
|
|
114
|
+
opt :edit, type: :boolean, desc: 'open recipe in $EDITOR (copies gem -> user dir first)'
|
|
115
|
+
opt :run, type: :boolean, desc: 'run a recipe without installing its bin (forwards remaining args)'
|
|
116
|
+
example 'recipes'
|
|
117
|
+
example 'recipes --install srt ~/bin/srt # write + chmod in one shot'
|
|
118
|
+
example 'recipes --install srt > ~/bin/srt && chmod +x $_'
|
|
119
|
+
example 'recipes --show srt'
|
|
120
|
+
example 'recipes --run srt extract movie.mp4'
|
|
121
|
+
example 'recipes --run srt -- --help # -- forwards flags to the recipe'
|
|
42
122
|
proc do |opts|
|
|
43
|
-
|
|
44
|
-
|
|
123
|
+
args = opts[:args]
|
|
124
|
+
if opts[:install]
|
|
125
|
+
Hammer::Builtins::RecipesActions.install(args[0], args[1])
|
|
126
|
+
elsif opts[:show]
|
|
127
|
+
Hammer::Builtins::RecipesActions.show(Hammer::Builtins::RecipesActions.require_name!(args[0], 'show'))
|
|
128
|
+
elsif opts[:path]
|
|
129
|
+
Hammer::Builtins::RecipesActions.path(Hammer::Builtins::RecipesActions.require_name!(args[0], 'path'))
|
|
130
|
+
elsif opts[:edit]
|
|
131
|
+
Hammer::Builtins::RecipesActions.edit(Hammer::Builtins::RecipesActions.require_name!(args[0], 'edit'))
|
|
132
|
+
elsif opts[:run]
|
|
133
|
+
name = Hammer::Builtins::RecipesActions.require_name!(args[0], 'run')
|
|
134
|
+
Hammer.recipe(name, args[1..])
|
|
135
|
+
else
|
|
136
|
+
Hammer::Builtins::RecipesActions.list
|
|
137
|
+
end
|
|
45
138
|
end
|
|
46
139
|
end
|
|
47
140
|
end
|
|
48
|
-
ensure
|
|
49
|
-
Thread.current[:hammer_builtins_loading] = nil
|
|
50
141
|
end
|
|
51
142
|
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
case action
|
|
60
|
-
when nil then list
|
|
61
|
-
when 'install' then install(name, rest.first)
|
|
62
|
-
when 'show' then show(require_name!(name, 'show'))
|
|
63
|
-
when 'path' then path(require_name!(name, 'path'))
|
|
64
|
-
when 'edit' then edit(require_name!(name, 'edit'))
|
|
65
|
-
when 'run' then Hammer.recipe(require_name!(name, 'run'), rest)
|
|
66
|
-
else
|
|
67
|
-
Shell.print_error "unknown action: #{action}"
|
|
68
|
-
Shell.say 'valid: install, show, path, edit, run (or omit for list)', :yellow
|
|
69
|
-
exit 1
|
|
70
|
-
end
|
|
143
|
+
# Writes ./Hammerfile with the canonical starter template. Refuses
|
|
144
|
+
# if one already exists - `init` must not clobber.
|
|
145
|
+
def write_starter_hammerfile
|
|
146
|
+
target = File.join(Dir.pwd, 'Hammerfile')
|
|
147
|
+
if File.exist?(target)
|
|
148
|
+
Shell.print_error "Hammerfile already exists at #{target}"
|
|
149
|
+
exit 1
|
|
71
150
|
end
|
|
151
|
+
File.write(target, Hammer::STARTER_HAMMERFILE)
|
|
152
|
+
Shell.say "created #{target}", :green
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Implementations of the `recipes` task's action flags, plus the
|
|
156
|
+
# no-flag listing view. Separate module so the task definition above
|
|
157
|
+
# stays small.
|
|
158
|
+
module RecipesActions
|
|
159
|
+
module_function
|
|
72
160
|
|
|
73
161
|
def require_name!(name, action)
|
|
74
162
|
return name if name
|
|
75
|
-
Shell.print_error "missing recipe name (usage:
|
|
163
|
+
Shell.print_error "missing recipe name (usage: recipes --#{action} NAME)"
|
|
76
164
|
exit 1
|
|
77
165
|
end
|
|
78
166
|
|
|
@@ -94,7 +182,7 @@ class Hammer
|
|
|
94
182
|
Shell.say "#{source}:", :yellow
|
|
95
183
|
items.each_key do |name|
|
|
96
184
|
_n, desc, installed = rows.find { |r| r.first == name }
|
|
97
|
-
status = installed ? "(installed: #{installed})" : "[install: hammer
|
|
185
|
+
status = installed ? "(installed: #{installed})" : "[install: hammer recipes --install #{name}]"
|
|
98
186
|
line = " #{name.ljust(width)} # #{desc}"
|
|
99
187
|
Shell.say line
|
|
100
188
|
Shell.say " #{' ' * width} #{status}", :gray
|
|
@@ -144,7 +232,7 @@ class Hammer
|
|
|
144
232
|
end
|
|
145
233
|
|
|
146
234
|
# For a gem recipe, offer to copy to user dir first so edits
|
|
147
|
-
# survive `hammer
|
|
235
|
+
# survive `hammer update`. Then exec $EDITOR on the file.
|
|
148
236
|
def edit(name)
|
|
149
237
|
path = Hammer::Recipe.path(name) or fail_unknown(name)
|
|
150
238
|
editor = ENV['EDITOR'] || ENV['VISUAL']
|
data/lib/hammer/recipe.rb
CHANGED
|
@@ -49,14 +49,14 @@ class Hammer
|
|
|
49
49
|
''
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
-
# Ruby wrapper text printed by `hammer
|
|
52
|
+
# Ruby wrapper text printed by `hammer recipes --install`. User
|
|
53
53
|
# redirects it to a file in PATH and chmods +x. The leading comment
|
|
54
54
|
# documents the canonical install command. Name is passed as a
|
|
55
55
|
# string literal so hyphenated names (`git-helper`) work too.
|
|
56
56
|
def stub(name)
|
|
57
57
|
<<~RUBY
|
|
58
58
|
#!/usr/bin/env ruby
|
|
59
|
-
# install: hammer
|
|
59
|
+
# install: hammer recipes --install #{name} > ~/bin/#{name} && chmod +x $_
|
|
60
60
|
require 'lux-hammer'
|
|
61
61
|
Hammer.recipe('#{name}', ARGV)
|
|
62
62
|
RUBY
|
data/lib/lux-hammer.rb
CHANGED
|
@@ -188,12 +188,7 @@ class Hammer
|
|
|
188
188
|
# namespace :users do ... end
|
|
189
189
|
# end
|
|
190
190
|
#
|
|
191
|
-
# `:self` is reserved for the `hammer` binary's built-ins (see
|
|
192
|
-
# Hammer::Builtins). User code that tries to open it raises.
|
|
193
191
|
def namespace(name, &block)
|
|
194
|
-
if name.to_s == 'self' && !Thread.current[:hammer_builtins_loading]
|
|
195
|
-
raise Error, "namespace 'self' is reserved for hammer's built-in commands"
|
|
196
|
-
end
|
|
197
192
|
sub = Class.new(Hammer)
|
|
198
193
|
# Track the top-level CLI class so cross-invocation
|
|
199
194
|
# (`hammer 'ns:cmd'`) from inside a namespaced command dispatches
|
|
@@ -369,13 +364,18 @@ class Hammer
|
|
|
369
364
|
argv = argv.dup
|
|
370
365
|
name = argv.shift
|
|
371
366
|
|
|
372
|
-
|
|
373
|
-
|
|
367
|
+
# Bare invocation OR leading flag (other than -h/--help) -> :default
|
|
368
|
+
# task. Re-prepend the flag so :default's option parser sees it.
|
|
369
|
+
# If no :default is defined, fall back to top-level help.
|
|
370
|
+
if name.nil? || (name.start_with?('-') && name != '-h' && name != '--help')
|
|
371
|
+
argv.unshift(name) if name
|
|
372
|
+
return dispatch_to_builtin('default', argv) { print_help }
|
|
374
373
|
end
|
|
375
374
|
|
|
375
|
+
# Explicit help requests -> :help task. Remaining positionals (e.g.
|
|
376
|
+
# `help build`, `help db:`) reach :help via opts[:args].
|
|
376
377
|
if name == 'help' || name == '-h' || name == '--help'
|
|
377
|
-
|
|
378
|
-
return print_help(target, extended: true)
|
|
378
|
+
return dispatch_to_builtin('help', argv) { print_help(argv.first, extended: true) }
|
|
379
379
|
end
|
|
380
380
|
|
|
381
381
|
# Trailing colon ("db:") -> namespace listing. Bare ":" lists root.
|
|
@@ -404,6 +404,16 @@ class Hammer
|
|
|
404
404
|
exit 1
|
|
405
405
|
end
|
|
406
406
|
|
|
407
|
+
# Run a built-in routing task (:default or :help) if defined,
|
|
408
|
+
# otherwise yield to the fallback block. The implicit run banner
|
|
409
|
+
# is suppressed (the user typed `hammer --version`, not `hammer
|
|
410
|
+
# default --version` - no point echoing the rewritten form).
|
|
411
|
+
def dispatch_to_builtin(name, argv)
|
|
412
|
+
cmd = commands[name]
|
|
413
|
+
return yield unless cmd
|
|
414
|
+
run_command(cmd, argv, full: name, quiet: true)
|
|
415
|
+
end
|
|
416
|
+
|
|
407
417
|
public
|
|
408
418
|
|
|
409
419
|
# Find a command by canonical name or alt within this class. Falls
|
|
@@ -526,14 +536,14 @@ class Hammer
|
|
|
526
536
|
end
|
|
527
537
|
end
|
|
528
538
|
|
|
529
|
-
def run_command(cmd, argv, full: nil)
|
|
539
|
+
def run_command(cmd, argv, full: nil, quiet: false)
|
|
530
540
|
# -h / --help is reserved on every command. Anywhere before a `--`
|
|
531
541
|
# stop-marker, it short-circuits to per-command help.
|
|
532
542
|
return print_command_help(cmd, full) if help_requested?(argv)
|
|
533
543
|
|
|
534
544
|
positional, opts = Parser.new(cmd.options).parse(argv)
|
|
535
545
|
opts[:args] = positional
|
|
536
|
-
print_run_banner(cmd, full || cmd.name, positional, opts)
|
|
546
|
+
print_run_banner(cmd, full || cmd.name, positional, opts) unless quiet || ENV['HAMMER_QUIET']
|
|
537
547
|
instance = new
|
|
538
548
|
run_before_hooks(instance, opts)
|
|
539
549
|
run_needs(cmd)
|
|
@@ -652,7 +662,7 @@ class Hammer
|
|
|
652
662
|
entries.each do |name, file|
|
|
653
663
|
desc = Hammer::Recipe.desc(file)
|
|
654
664
|
installed = Hammer::Recipe.installed_path(name)
|
|
655
|
-
suffix = installed ? "(installed: #{installed})" : "[install: #{program_name}
|
|
665
|
+
suffix = installed ? "(installed: #{installed})" : "[install: #{program_name} recipes --install #{name}]"
|
|
656
666
|
Shell.say " #{name.ljust(width)} # #{desc}"
|
|
657
667
|
Shell.say " #{' ' * width} #{suffix}", :gray
|
|
658
668
|
end
|
|
@@ -661,8 +671,8 @@ class Hammer
|
|
|
661
671
|
# `extended:` is accepted for parity with `print_help` but intentionally
|
|
662
672
|
# not used here - the global-flags / Hammerfile-example / footer block
|
|
663
673
|
# is root-help-only. A namespace listing is just the commands under
|
|
664
|
-
# that prefix; tool-meta noise (
|
|
665
|
-
#
|
|
674
|
+
# that prefix; tool-meta noise (Recipes: section) is reserved for
|
|
675
|
+
# `hammer --help` at the top level.
|
|
666
676
|
def print_namespace_help(prefix, ns, full: false, extended: false)
|
|
667
677
|
Shell.say "Usage: #{program_name} #{prefix}:COMMAND [ARGS]", :cyan
|
|
668
678
|
rows = []
|
|
@@ -707,14 +717,16 @@ class Hammer
|
|
|
707
717
|
print_footer unless hammer_bin
|
|
708
718
|
end
|
|
709
719
|
|
|
710
|
-
#
|
|
711
|
-
#
|
|
712
|
-
#
|
|
720
|
+
# Listed under `Default task options:` in `--help` so users see what
|
|
721
|
+
# flags fire on bare-flag invocation (`hammer --version` etc).
|
|
722
|
+
# Re-rendered from the live `:default` task so user-defined
|
|
723
|
+
# overrides surface their own flags here automatically.
|
|
713
724
|
def print_global_flags
|
|
714
|
-
|
|
725
|
+
default = root.commands['default']
|
|
726
|
+
return unless default && !default.options.empty?
|
|
715
727
|
Shell.say ''
|
|
716
|
-
Shell.say '
|
|
717
|
-
Shell.say
|
|
728
|
+
Shell.say 'Default task options:', :yellow
|
|
729
|
+
default.options.each { |o| Shell.say " #{o.usage}" }
|
|
718
730
|
end
|
|
719
731
|
|
|
720
732
|
def print_footer
|
|
@@ -722,35 +734,13 @@ class Hammer
|
|
|
722
734
|
Shell.say "powered by hammer - #{HOMEPAGE}", :gray
|
|
723
735
|
end
|
|
724
736
|
|
|
725
|
-
#
|
|
726
|
-
#
|
|
727
|
-
#
|
|
737
|
+
# Hammerfile cheat-sheet shown under `hammer --help`. Same content
|
|
738
|
+
# as `hammer --init` writes - single source of truth via
|
|
739
|
+
# `Hammer::STARTER_HAMMERFILE`. For exhaustive docs see `hammer agents`.
|
|
728
740
|
def print_hammerfile_example
|
|
729
741
|
Shell.say ''
|
|
730
742
|
Shell.say 'Hammerfile example:', :yellow
|
|
731
|
-
Shell.say
|
|
732
|
-
desc 'My project tools - build, deploy, test'
|
|
733
|
-
|
|
734
|
-
task :hello do
|
|
735
|
-
desc 'Greet someone'
|
|
736
|
-
example 'hello world --loud'
|
|
737
|
-
opt :loud, type: :boolean, alias: :l
|
|
738
|
-
proc do |opts|
|
|
739
|
-
msg = "hello \#{opts[:args].first || 'world'}"
|
|
740
|
-
say(opts[:loud] ? msg.upcase : msg, :cyan)
|
|
741
|
-
end
|
|
742
|
-
end
|
|
743
|
-
|
|
744
|
-
namespace :db do
|
|
745
|
-
before { hammer :env }
|
|
746
|
-
|
|
747
|
-
task :migrate do
|
|
748
|
-
desc 'Run migrations'
|
|
749
|
-
needs 'db:check'
|
|
750
|
-
proc { sh 'bin/rails db:migrate' }
|
|
751
|
-
end
|
|
752
|
-
end
|
|
753
|
-
RUBY
|
|
743
|
+
Shell.say Hammer::STARTER_HAMMERFILE
|
|
754
744
|
end
|
|
755
745
|
|
|
756
746
|
def print_command_list(klass, prefix = nil)
|
|
@@ -896,7 +886,7 @@ class Hammer
|
|
|
896
886
|
Shell.print_error "unknown recipe: #{name}"
|
|
897
887
|
Shell.say 'available recipes:', :yellow
|
|
898
888
|
Recipe.all.keys.sort.each { |n| Shell.say " #{n}" }
|
|
899
|
-
Shell.say 'try `hammer
|
|
889
|
+
Shell.say 'try `hammer recipes` to list with descriptions', :gray
|
|
900
890
|
exit 1
|
|
901
891
|
end
|
|
902
892
|
|
|
@@ -919,12 +909,45 @@ class Hammer
|
|
|
919
909
|
end
|
|
920
910
|
end
|
|
921
911
|
|
|
922
|
-
#
|
|
912
|
+
# Canonical starter Hammerfile. Written verbatim by `hammer --init`
|
|
913
|
+
# and shown inline by `print_hammerfile_example` (the `Hammerfile
|
|
914
|
+
# example:` block in `hammer --help`) - one source of truth for both.
|
|
915
|
+
# Annotated as a mini tutorial of the common DSL surface in one task.
|
|
916
|
+
# Single-quoted heredoc so file contents pass through unescaped.
|
|
917
|
+
STARTER_HAMMERFILE ||= <<~'RUBY'
|
|
918
|
+
desc 'My project tools'
|
|
919
|
+
|
|
920
|
+
# `namespace :name do ... end` groups tasks under a colon prefix.
|
|
921
|
+
# Address as `hammer demo:greet`.
|
|
922
|
+
namespace :demo do
|
|
923
|
+
# run before every task - global, or on a namespace
|
|
924
|
+
before { say.gray "[demo] booting in #{Dir.pwd}" }
|
|
925
|
+
|
|
926
|
+
task :greet do
|
|
927
|
+
desc 'Demo task - shows the common DSL features in one place'
|
|
928
|
+
example 'demo:greet world --from=alice --loud --times=3'
|
|
929
|
+
alt :hi # `hammer demo:hi` also dispatches here
|
|
930
|
+
|
|
931
|
+
opt :from, default: 'anon', desc: 'who is greeting'
|
|
932
|
+
opt :loud, type: :boolean, alias: :l, desc: 'shout the greeting'
|
|
933
|
+
opt :times, type: :integer, default: 1, desc: 'repeat N times'
|
|
934
|
+
|
|
935
|
+
proc do |opts|
|
|
936
|
+
who = opts[:args].first || 'world'
|
|
937
|
+
msg = "hello #{who} from #{opts[:from]}"
|
|
938
|
+
msg = msg.upcase if opts[:loud]
|
|
939
|
+
opts[:times].times { say msg, :cyan }
|
|
940
|
+
end
|
|
941
|
+
end
|
|
942
|
+
end
|
|
943
|
+
RUBY
|
|
944
|
+
|
|
945
|
+
# Default install dir used by install.sh and `hammer update`.
|
|
923
946
|
SELF_UPDATE_DIR ||= File.expand_path('~/.local/share/lux-hammer')
|
|
924
947
|
SELF_UPDATE_REPO ||= 'https://github.com/dux/hammer.git'
|
|
925
948
|
SELF_INSTALL_URL ||= 'https://raw.githubusercontent.com/dux/hammer/main/install.sh'
|
|
926
949
|
|
|
927
|
-
# `hammer
|
|
950
|
+
# `hammer update`: pull main in the install-script checkout and
|
|
928
951
|
# reinstall the gem. Assumes the install.sh layout - if the dir is
|
|
929
952
|
# missing, point the user at the curl-pipe installer.
|
|
930
953
|
def self.self_update
|
|
@@ -959,31 +982,25 @@ class Hammer
|
|
|
959
982
|
# finds a Hammerfile, evaluates it as the block DSL, then dispatches
|
|
960
983
|
# ARGV against the resulting CLI.
|
|
961
984
|
#
|
|
962
|
-
#
|
|
963
|
-
#
|
|
964
|
-
#
|
|
985
|
+
# `--system` forces the no-Hammerfile branch even from inside a
|
|
986
|
+
# project - the escape hatch for reaching `recipes`/`init` when a
|
|
987
|
+
# user-defined task tree would otherwise own the root.
|
|
965
988
|
def self.cli(argv = ARGV)
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
if (i = argv.index('--update'))
|
|
969
|
-
argv = argv.dup
|
|
970
|
-
argv.delete_at(i)
|
|
971
|
-
argv.unshift('self:update')
|
|
972
|
-
end
|
|
973
|
-
|
|
974
|
-
wants_builtins = builtins_triggered?(argv)
|
|
989
|
+
argv = argv.dup
|
|
990
|
+
force_system = !!argv.delete('--system')
|
|
975
991
|
|
|
976
|
-
path = find_hammerfile(Dir.pwd)
|
|
992
|
+
path = force_system ? nil : find_hammerfile(Dir.pwd)
|
|
977
993
|
unless path
|
|
978
|
-
# No Hammerfile
|
|
979
|
-
#
|
|
980
|
-
#
|
|
981
|
-
if
|
|
994
|
+
# No Hammerfile (or --system) - all built-ins are reachable. Bare
|
|
995
|
+
# `hammer`, `hammer recipes`, `hammer update`, `hammer agents`,
|
|
996
|
+
# `hammer version`, `hammer init` all work.
|
|
997
|
+
if force_system || dispatches_to_builtin?(argv) || looks_like_builtin?(argv)
|
|
982
998
|
klass = Class.new(Hammer)
|
|
983
999
|
klass.instance_variable_set(:@hammer_binary, true)
|
|
984
1000
|
klass.program_name
|
|
985
1001
|
require_relative 'hammer/builtins'
|
|
986
|
-
Hammer::Builtins.
|
|
1002
|
+
Hammer::Builtins.register_core(klass)
|
|
1003
|
+
Hammer::Builtins.register_no_project(klass)
|
|
987
1004
|
klass.start(argv)
|
|
988
1005
|
return
|
|
989
1006
|
end
|
|
@@ -1004,24 +1021,17 @@ class Hammer
|
|
|
1004
1021
|
|
|
1005
1022
|
Shell.say "create one - example:"
|
|
1006
1023
|
puts
|
|
1007
|
-
Shell.say
|
|
1008
|
-
desc 'My project tools'
|
|
1009
|
-
|
|
1010
|
-
task :hello do
|
|
1011
|
-
desc 'say hello'
|
|
1012
|
-
proc do |opts|
|
|
1013
|
-
say.green "hello \#{opts[:args].first || 'world'}"
|
|
1014
|
-
end
|
|
1015
|
-
end
|
|
1016
|
-
RUBY
|
|
1024
|
+
Shell.say STARTER_HAMMERFILE
|
|
1017
1025
|
Shell.say ''
|
|
1018
|
-
|
|
1026
|
+
bin = File.basename($PROGRAM_NAME)
|
|
1027
|
+
Shell.say "tip: run `#{bin} init` to drop the example above into ./Hammerfile", :gray
|
|
1028
|
+
Shell.say "tip: run `#{bin} agents` for AI-friendly Hammerfile authoring docs", :gray
|
|
1019
1029
|
exit 1
|
|
1020
1030
|
end
|
|
1021
1031
|
|
|
1022
1032
|
klass = Class.new(Hammer)
|
|
1023
1033
|
# Mark this class as the `hammer` binary's root so help output can
|
|
1024
|
-
# surface binary-only sections (`Recipes
|
|
1034
|
+
# surface binary-only sections (`Recipes:` listing).
|
|
1025
1035
|
klass.instance_variable_set(:@hammer_binary, true)
|
|
1026
1036
|
# Resolve before chdir so paths like `bin/foo` stay relative to the
|
|
1027
1037
|
# cwd the user actually invoked from. `program_name` memoizes.
|
|
@@ -1036,24 +1046,37 @@ class Hammer
|
|
|
1036
1046
|
# are NOT visible during Hammerfile evaluation, only inside handlers.
|
|
1037
1047
|
Hammer::Dotenv.load(Dir.pwd) if klass.dotenv_enabled?
|
|
1038
1048
|
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1049
|
+
# Core built-ins register AFTER Hammerfile eval so user-defined
|
|
1050
|
+
# tasks win (the `unless commands.key?(...)` guards in register_core
|
|
1051
|
+
# skip the built-in when overridden - no redefinition warning).
|
|
1052
|
+
# `register_no_project` (:recipes, :init) is intentionally NOT
|
|
1053
|
+
# called here - those would clash too easily with user tasks. Use
|
|
1054
|
+
# `hammer --system recipes` to reach them from inside a project.
|
|
1055
|
+
require_relative 'hammer/builtins'
|
|
1056
|
+
Hammer::Builtins.register_core(klass)
|
|
1043
1057
|
|
|
1044
1058
|
klass.start(argv)
|
|
1045
1059
|
end
|
|
1046
1060
|
|
|
1047
|
-
# True if argv
|
|
1048
|
-
#
|
|
1049
|
-
#
|
|
1050
|
-
#
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
argv.
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1061
|
+
# True if argv goes through a built-in dispatch path (`:default` or
|
|
1062
|
+
# `:help`) - meaning bare `hammer`, leading-flag invocations like
|
|
1063
|
+
# `hammer -h`, or explicit help requests. These don't need a project
|
|
1064
|
+
# Hammerfile to run.
|
|
1065
|
+
def self.dispatches_to_builtin?(argv)
|
|
1066
|
+
return true if argv.empty?
|
|
1067
|
+
first = argv.first
|
|
1068
|
+
first == 'help' || first == '-h' || first == '--help' || first.start_with?('-')
|
|
1069
|
+
end
|
|
1070
|
+
|
|
1071
|
+
# True if argv names a built-in task (`recipes`, `update`, `agents`,
|
|
1072
|
+
# `version`, `init`). Used in the no-Hammerfile branch to wake up the
|
|
1073
|
+
# built-ins for invocations like `hammer recipes` that aren't a flag
|
|
1074
|
+
# or help request.
|
|
1075
|
+
BUILTIN_TASKS ||= %w[recipes update agents version init].freeze
|
|
1076
|
+
def self.looks_like_builtin?(argv)
|
|
1077
|
+
first = argv.first
|
|
1078
|
+
return false unless first
|
|
1079
|
+
BUILTIN_TASKS.include?(first) || BUILTIN_TASKS.any? { |t| first.start_with?("#{t}:") }
|
|
1057
1080
|
end
|
|
1058
1081
|
|
|
1059
1082
|
# Walk up the directory tree looking for a Hammerfile.
|
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,7 +1,7 @@
|
|
|
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
|
|
@@ -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
|