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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 62eaadbe6e088375e7762d4b16050df8b17f3242a5e92bf611776ff58b3c3e91
4
- data.tar.gz: 765045381a51f5e6cea55f8250ed0543ead5ba67f765241a3e8db3faa108d92b
3
+ metadata.gz: 7e19b144891467fd0209c27265352a153f340820d14384d8deacb46e8ef58532
4
+ data.tar.gz: 279c7ac83d6f597d28e42b799e5cfdc917b0ce10f88d2950d9f0fe562ed674de
5
5
  SHA512:
6
- metadata.gz: 9de3baba0031948bb5e11c37f282b6d8e0af7a33d703d69f4d6c7026473f1985207c186882a19c313454cdc6e0eea310a109f708a393b269e1c043695d4daf0d
7
- data.tar.gz: ff7e9f42651d68b8ba7f3f649693fc66171dc758d9b98f3ff58859f16419ebbeee976bae4931b4d4008acaad9b7635f5250a24d6e2f49df823ce47ea5e882c46
6
+ metadata.gz: e9baea7eb17ad8b98ec3f7778431c603a03283e029a95e9140f83026aa88f587e01a4e87bf7dc09b6538a95f463f067293daf147df02e3454f161d737c9e6bad
7
+ data.tar.gz: 4b41c8875111734dfbe6402b97eaa03be209081f3086096cde94343eacbb1c5fd18d2933575ad1818ca08e6ffae363fd695cb107d5ddb7068610580bd04b7289
data/.version CHANGED
@@ -1 +1 @@
1
- 0.3.6
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 # Lazy-loaded `self:` namespace (recipe, ai, update)
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. Also lazy-registers the
147
- `self:` namespace when argv shows the user wants help or a
148
- `self:`-prefixed command. Not part of the user-facing surface -
149
- don't recommend it in examples; `Hammer.run` is what library users
150
- should reach for.
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) prints just the command listing (with a top gray
210
- `lux-hammer VERSION - <homepage>` banner for the hammer binary).
211
- * `hammer -h` / `hammer --help` adds global flags, a small Hammerfile
212
- example, and the footer link on top of the same listing.
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 --update # git pull main + rebuild + reinstall
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 self:recipe
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 self:recipe install srt]
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 (you control the path; nothing is written for you):
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 self:recipe install srt > ~/bin/srt && chmod +x ~/bin/srt
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 self:recipe`. The file
1036
- body uses the same DSL as a Hammerfile - `task`, `namespace`, `before`,
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 self:recipe install json > ~/bin/json && chmod +x ~/bin/json
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 `self:recipe` actions
1139
+ ### Other `recipes` actions
1062
1140
 
1063
1141
  ```sh
1064
- hammer self:recipe # list all
1065
- hammer self:recipe install # interactive picker, then prints stub
1066
- hammer self:recipe show <NAME> # cat the recipe source
1067
- hammer self:recipe path <NAME> # absolute path
1068
- hammer self:recipe edit <NAME> # open in $EDITOR (copies gem -> user dir first)
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
@@ -1,78 +1,166 @@
1
1
  class Hammer
2
- # Lazy-loaded "built-ins" attached under the reserved `self:` namespace
3
- # of the `hammer` binary. Hosts management commands (recipes, AGENTS.md
4
- # dump, self-update) that should not appear on every user-command run.
5
- # `Hammer.cli` only calls `register` when argv shows the user is
6
- # asking for help or invoking a `self:`-prefixed command.
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
- # Wire the `self:` namespace into `klass`. Idempotent - safe to call
11
- # twice; the second call replaces the existing subclass.
12
- def register(klass)
13
- Thread.current[:hammer_builtins_loading] = true
14
- klass.namespace(:self) do
15
- task :ai do
16
- desc 'Print AGENTS.md (AI-friendly Hammerfile authoring docs)'
17
- proc { Hammer.print_ai_help }
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 'Update lux-hammer from github main (requires install.sh checkout)'
65
+ desc 'Rebuild + reinstall lux-hammer from main'
22
66
  proc { Hammer.self_update }
23
67
  end
68
+ end
69
+ end
24
70
 
25
- task :recipe do
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. First positional argument is the action.
28
-
29
- (no args) list all recipes
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
- example 'self:recipe'
37
- example 'self:recipe install srt ~/bin/srt # write + chmod in one shot'
38
- example 'self:recipe install srt > ~/bin/srt && chmod +x $_'
39
- example 'self:recipe show srt'
40
- example 'self:recipe run srt extract movie.mp4'
41
- example 'self:recipe run srt -- --help # -- forwards flags to the recipe'
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
- action, name, *rest = opts[:args]
44
- Hammer::Builtins::Recipes.dispatch(action, name, rest)
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
- # Implementations of the `self:recipe <action>` sub-commands, plus
53
- # the no-action listing view. Kept in its own module so the
54
- # namespace definition above stays small and skimmable.
55
- module Recipes
56
- module_function
57
-
58
- def dispatch(action, name, rest = [])
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: self:recipe #{action} NAME)"
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 self:recipe install #{name}]"
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 self:update`. Then exec $EDITOR on the file.
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 self:recipe install`. User
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 self:recipe install #{name} > ~/bin/#{name} && chmod +x $_
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
- if name.nil?
373
- return print_help
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
- target = argv.shift
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} self:recipe install #{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 (self:, Recipes:, --update alias) is
665
- # reserved for `hammer --help` at the top level.
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
- # Global flags only exist when invoked via the `hammer` binary
711
- # (see `Hammer.cli`), not for user-built CLIs that call `start`
712
- # on their own subclass.
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
- return unless root.instance_variable_get(:@hammer_binary)
725
+ default = root.commands['default']
726
+ return unless default && !default.options.empty?
715
727
  Shell.say ''
716
- Shell.say 'Global:', :yellow
717
- Shell.say ' --update # alias for `self:update`'
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
- # Small Hammerfile cheat-sheet shown under `hammer --help`. Touches
726
- # the main surface area (task/desc/example/opt, namespace, before,
727
- # needs, sh, say) without trying to be exhaustive - that's --ai's job.
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 <<~RUBY
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 self:recipe` to list with descriptions', :gray
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
- # Default install dir used by install.sh and `hammer --update`.
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 --update`: pull main in the install-script checkout and
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
- # The `self:` namespace (recipe management, AGENTS.md dump, self-
963
- # update) is registered on-demand when the user invokes help or
964
- # types a `self:` path - see `builtins_triggered?`.
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
- # `--update` is a back-compat alias for `self:update`. Rewrite so
967
- # normal dispatch handles it (after builtins registration).
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 - still allow `hammer self:*` commands to run
979
- # (they don't depend on a project). Otherwise print the help-ish
980
- # "create one" message.
981
- if wants_builtins
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.register(klass)
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 <<~RUBY
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
- Shell.say "tip: run `#{File.basename($PROGRAM_NAME)} self:ai` for AI-friendly Hammerfile authoring docs", :gray
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:`, `self:` namespace).
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
- if wants_builtins
1040
- require_relative 'hammer/builtins'
1041
- Hammer::Builtins.register(klass)
1042
- end
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 references the `self:` namespace or asks for help -
1048
- # any of which means the user wants to see / invoke the built-ins.
1049
- # Bare `hammer` (empty argv) does NOT trigger - the no-args listing
1050
- # stays a clean project-command view without `self:` or `Recipes:`.
1051
- # Cheap scan, runs once per invocation.
1052
- def self.builtins_triggered?(argv)
1053
- argv.any? do |a|
1054
- a == '--help' || a == '-h' || a == 'help' ||
1055
- a == 'self' || a.start_with?('self:')
1056
- end
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.6
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