lux-hammer 0.3.12 → 0.3.13

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: 75362aef2908fdd2a3f4e32ab8c6ce7a4c4ae52fce795f5bca5437325a92da49
4
- data.tar.gz: 0d318814ed2cdd0da35ca2b66e7637c62d5d2efece0cb24f60da1c5e02f4ad1f
3
+ metadata.gz: 411174afd97cefbe9e7e3309c25233f465b9458ee0dc710e67077725658876c0
4
+ data.tar.gz: f4b64f7897fd8c67412fdbe418ad51b96eeac1ebb3e87ce9df5b97651a4a9ff6
5
5
  SHA512:
6
- metadata.gz: 64a8ce40bf7fc4986b6b0796c72fb9a8e9a13b2acb70c4aaa76cd68d5f68158bc9fbde7d5a0a5f3e586978b97f849f712b5addef336754b896e901f71c9feec2
7
- data.tar.gz: 6f12a27b72098ff513ecc216ee8f2c6deb08e3c3a29cf4f1a7e9c6dfb7750a9b00ad13fe514f66fdda7b7a837b62e2526d1af04ca1af85deed1435e2d34b30a3
6
+ metadata.gz: ba1364fcff70fefa529a2e7586ee33f7826b43ee7cd3ebd5e454f0bce82131b427cf320d85d8d911edc3a11762196de7db9964c6877959548eb4c6753e5ac063
7
+ data.tar.gz: 5b21c08ac766b1af02b1a0d49b7fb3129fffd94b0c13fd07c7a5995851c21447e6a437848f25ef30f364bfccb94f1bac51046630dbbd574aaa109a61a738e893
data/.version CHANGED
@@ -1 +1 @@
1
- 0.3.12
1
+ 0.3.13
data/AGENTS.md CHANGED
@@ -156,17 +156,18 @@ Runtime cross-invocation:
156
156
  errors if none found anywhere up the tree (unless the invocation
157
157
  routes to a built-in - see `dispatches_to_builtin?` /
158
158
  `looks_like_builtin?`, true for bare invocation / leading flag /
159
- explicit help / a built-in task name). After evaluating the
160
- Hammerfile, registers the always-on core built-ins (`:default`,
161
- `:help`, `:update`, `:agents`, `:version`) via
162
- `Hammer::Builtins.register_core` - each guarded by
163
- `unless commands.key?(...)` so a user-defined task wins silently.
164
- No-project-only built-ins (`:recipes`, `:init`) are NOT registered
165
- when a Hammerfile loaded - reach them with `--system`. The
166
- `--system` flag is peeled off argv at the top and forces the
167
- no-Hammerfile branch. Not part of the user-facing surface - don't
168
- recommend it in examples; `Hammer.run` is what library users should
169
- reach for.
159
+ explicit help / an `h:`-namespace path). After evaluating the
160
+ Hammerfile, registers the built-ins via `Hammer::Builtins.register`:
161
+ `:default` at the root (backs bare invocation, carries `--version`)
162
+ plus the `h:` namespace (`h:help`, `h:update`, `h:agents`,
163
+ `h:version`, `h:recipes`, `h:init`). The full set registers in every
164
+ context - living under `h:` means they can't collide with project
165
+ root tasks, so there's no core/no-project split. Each is guarded by
166
+ `unless commands.key?(...)` so a user who reopens `namespace :h` wins
167
+ silently. The `--system` flag is peeled off argv at the top and
168
+ forces the no-Hammerfile branch. Not part of the user-facing surface
169
+ - don't recommend it in examples; `Hammer.run` is what library users
170
+ should reach for.
170
171
  * `Hammer.recipe(name, argv = ARGV)` - entry for recipe stubs in PATH.
171
172
  Loads `<gem>/recipes/<name>.rb` (or its user-dir override), runs as
172
173
  a standalone CLI with `program_name = name`. No Hammerfile lookup,
@@ -239,45 +240,50 @@ explicit ADR-level discussion. Keys:
239
240
 
240
241
  ## Built-in tasks (user-overridable)
241
242
 
242
- `Hammer::Builtins` defines two registration entry points; both register
243
- **after** Hammerfile evaluation and skip each task when the user
244
- already defined it (`unless klass.commands.key?(name)` - no
245
- redefinition warning).
246
-
247
- * `register_core(klass)` - always called. Registers `:default`,
248
- `:help`, `:update`, `:agents`, `:version`. These coexist with
249
- Hammerfile tasks.
250
- * `register_no_project(klass)` - called only on the no-Hammerfile
251
- branch of `Hammer.cli` (which `--system` also routes through).
252
- Registers `:recipes` and `:init` - tasks that would collide too
253
- easily with user tasks inside a project.
254
-
255
- No reserved namespace. Names like `:self`, `:recipes`, `:update` can
256
- all be defined freely in a Hammerfile; the built-ins yield.
243
+ `Hammer::Builtins.register(klass)` is the single registration entry
244
+ point. It runs **after** Hammerfile evaluation and skips each task when
245
+ the user already defined it (`unless commands.key?(name)` - no
246
+ redefinition warning). The same set registers in every context (project
247
+ or bare binary): `:default` at the root, everything else under the
248
+ reserved `h:` namespace.
249
+
250
+ * Root: `:default` (hidden, carries `--version` / `-v`).
251
+ * `h:` namespace: `h:help`, `h:update`, `h:agents`, `h:version`,
252
+ `h:recipes`, `h:init`.
253
+
254
+ `h:` is the reserved built-in namespace - keeping the tool-meta commands
255
+ there means they never collide with a project's root tasks, so there's
256
+ no core/no-project split and no hidden-desc bookkeeping. Other names
257
+ (`:self`, `:recipes` at the root, ...) stay free for Hammerfiles. The
258
+ `h:` subclass is flagged `@builtin_namespace`, which prunes it from the
259
+ compact listing - the built-ins are always dispatchable but only show
260
+ (under an `h:` section) in the extended `--help` view.
257
261
 
258
262
  Task contracts:
259
263
 
260
- * `:default` - hidden (no desc). Just prints `self.class.root.print_help`
261
- (brief). Fires for bare `hammer` and leading-flag invocations where
262
- the first token starts with `-` and isn't `-h` / `--help` (see
264
+ * `:default` - hidden (no desc), at the root. Prints
265
+ `self.class.root.print_help` (brief) and exposes a `--version` flag.
266
+ Fires for bare `hammer` and leading-flag invocations where the first
267
+ token starts with `-` and isn't `-h` / `--help` (see
263
268
  `Hammer.dispatch`). User overrides typically declare flag opts and
264
269
  fall through to `hammer :help` when none matched.
265
- * `:help` - has a desc, shows in listings. Calls
266
- `self.class.root.print_help(opts[:args].first, extended: true)`.
267
- Fires for `help` / `-h` / `--help` at the top level.
268
- * `:update` / `:agents` / `:version` - thin wrappers around
270
+ * `h:help` - has a desc; shows in the `--help` listing. Calls
271
+ `self.class.root.print_help(opts[:args].first, extended: true)`. The
272
+ conventional `help` / `-h` / `--help` at the top level still reach it
273
+ (handled directly in `Hammer.dispatch`).
274
+ * `h:update` / `h:agents` / `h:version` - thin wrappers around
269
275
  `Hammer.self_update` / `Hammer.print_ai_help` / `puts Hammer::VERSION`.
270
- * `:recipes` - all recipe-management actions on one task, picked via
276
+ * `h:recipes` - all recipe-management actions on one task, picked via
271
277
  boolean opts: `--install [NAME [TARGET]]`, `--show NAME`,
272
278
  `--path NAME`, `--edit NAME`, `--run NAME [ARGS]` (use `--` to
273
279
  forward flags through). Bare invocation lists.
274
- * `:init` - writes `Hammer::STARTER_HAMMERFILE` to `./Hammerfile`;
280
+ * `h:init` - writes `Hammer::STARTER_HAMMERFILE` to `./Hammerfile`;
275
281
  refuses if one exists.
276
282
 
277
- Both `:default` and `:help` are invoked via `run_command(cmd, argv,
278
- full: name, quiet: true)` - the gray `> prog cmd ...` banner is
279
- suppressed because the user didn't type `hammer default` /
280
- `hammer help` literally.
283
+ The `:default` task and the `help` / `-h` / `--help` requests are
284
+ invoked via `run_command(cmd, argv, full: name, quiet: true)` - the
285
+ gray `> prog cmd ...` banner is suppressed because the user didn't type
286
+ `hammer default` / `hammer h:help` literally.
281
287
  * Commands listed flat with colon paths, grouped by top-level namespace.
282
288
  * Bare namespace (`hammer db`) prints the same listing scoped to that
283
289
  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 h:update # git pull main + rebuild + reinstall
77
77
  ```
78
78
 
79
79
  ## Quick start
@@ -537,41 +537,43 @@ end
537
537
  `hammer` and `hammer db` won't list `env`, but `hammer env`,
538
538
  `hammer :env` from another proc, and `before { hammer :env }` all work.
539
539
 
540
- ## Built-in tasks (all overridable)
540
+ ## Built-in tasks
541
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.
542
+ The `hammer` binary auto-registers a small set of built-in tasks. All
543
+ the tool-meta commands live under the reserved `h:` namespace so they
544
+ can never collide with your project's own root tasks. They register the
545
+ same way with or without a Hammerfile and are always dispatchable, but
546
+ to keep the bare listing focused on your project they only show up
547
+ (under an `h:` section) in the extended `hammer --help` view.
546
548
 
547
- Always available (with or without a Hammerfile):
549
+ Handled at the root (so the conventional invocations keep working):
548
550
 
549
551
  * `: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.
552
+ Hidden from listings. Prints the brief help by default and carries a
553
+ `--version` / `-v` flag; override to wire up your own global flags.
554
+ * `hammer help [TARGET]`, `hammer -h`, `hammer --help` - prints the
555
+ extended help view; `TARGET` accepts a command path or a `ns:` prefix.
556
+ (Also available as `hammer h:help`.)
558
557
 
559
- Only when no Hammerfile is loaded (or `--system` is passed - see below):
558
+ Under the `h:` namespace:
560
559
 
561
- * `:recipes` - list / install / show / edit recipes.
562
- * `:init` - write a starter Hammerfile in cwd (refuses if one exists).
560
+ * `h:update` - rebuild + reinstall lux-hammer from main.
561
+ * `h:agents` - dump AGENTS.md (AI-friendly Hammerfile authoring docs).
562
+ * `h:version` - print the lux-hammer version.
563
+ * `h:recipes` - list / install / show / edit recipes.
564
+ * `h:init` - write a starter Hammerfile in cwd (refuses if one exists).
563
565
 
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
+ Each is guarded by `unless commands.key?` within the namespace, so you
567
+ can override one by reopening `namespace :h` in your Hammerfile.
568
+
569
+ `--system` forces the no-Hammerfile branch - the Hammerfile in the
570
+ current tree (if any) isn't loaded for that invocation:
566
571
 
567
572
  ```sh
568
- hammer --system recipes # list recipes from anywhere
569
- hammer --system recipes --install srt ~/bin/srt
573
+ hammer --system h:recipes # list recipes, ignoring any local Hammerfile
574
+ hammer --system h:recipes --install srt ~/bin/srt
570
575
  ```
571
576
 
572
- `--system` forces the no-Hammerfile branch - the Hammerfile in the
573
- current tree (if any) isn't loaded for that invocation.
574
-
575
577
  Customize bare `hammer` by replacing `:default`:
576
578
 
577
579
  ```ruby
@@ -1106,20 +1108,21 @@ A **recipe** is a standalone Hammerfile-style script bundled inside the
1106
1108
  top-level binary in your `PATH` - so `srt` becomes a real command, not
1107
1109
  `hammer srt:shift`.
1108
1110
 
1109
- Recipe management lives under the `recipes` task. From inside a
1110
- project the task isn't registered (so it can't shadow user tasks);
1111
- pass `--system` to reach it from anywhere.
1111
+ Recipe management lives under the `h:recipes` task, reachable from
1112
+ anywhere (inside a project the `h:` namespace can't shadow your root
1113
+ tasks). `--system` skips loading a local Hammerfile if you want a clean
1114
+ environment.
1112
1115
 
1113
1116
  Listing what's available:
1114
1117
 
1115
1118
  ```sh
1116
- $ hammer recipes # from any non-project dir
1117
- $ hammer --system recipes # from inside a project
1119
+ $ hammer h:recipes # from any directory
1120
+ $ hammer --system h:recipes # ignoring any local Hammerfile
1118
1121
  gem:
1119
1122
  srt # Subtitle (.srt) toolkit - shift timestamps, show stats
1120
- [install: hammer recipes --install srt]
1123
+ [install: hammer h:recipes --install srt]
1121
1124
  llm # personal LLM utility CLI (memory store, prompt-token expander, ...)
1122
- [install: hammer recipes --install llm]
1125
+ [install: hammer h:recipes --install llm]
1123
1126
  ```
1124
1127
 
1125
1128
  Installing one. With no TARGET, the stub is printed to stdout (you
@@ -1127,8 +1130,8 @@ redirect it yourself); with a TARGET path, lux-hammer writes the file
1127
1130
  and chmods +x in one step:
1128
1131
 
1129
1132
  ```sh
1130
- $ hammer recipes --install srt ~/bin/srt # write + chmod
1131
- $ hammer recipes --install srt > ~/bin/srt && chmod +x ~/bin/srt
1133
+ $ hammer h:recipes --install srt ~/bin/srt # write + chmod
1134
+ $ hammer h:recipes --install srt > ~/bin/srt && chmod +x ~/bin/srt
1132
1135
  $ srt --help
1133
1136
  Usage: srt COMMAND [ARGS]
1134
1137
 
@@ -1145,7 +1148,7 @@ so the recipe always runs the version currently in the gem.
1145
1148
  ### Authoring your own
1146
1149
 
1147
1150
  Drop a plain `.rb` file in `~/.config/hammer/recipes/`. The first
1148
- `# desc: ...` comment is what shows in `hammer recipes`. The file body
1151
+ `# desc: ...` comment is what shows in `hammer h:recipes`. The file body
1149
1152
  uses the same DSL as a Hammerfile - `task`, `namespace`, `before`,
1150
1153
  `load`. Example `~/.config/hammer/recipes/json.rb`:
1151
1154
 
@@ -1165,22 +1168,22 @@ end
1165
1168
  Install it the same way:
1166
1169
 
1167
1170
  ```sh
1168
- $ hammer recipes --install json ~/bin/json
1171
+ $ hammer h:recipes --install json ~/bin/json
1169
1172
  ```
1170
1173
 
1171
1174
  User-dir recipes override gem recipes with the same name, so you can
1172
1175
  fork without forking.
1173
1176
 
1174
- ### Other `recipes` actions
1177
+ ### Other `h:recipes` actions
1175
1178
 
1176
1179
  ```sh
1177
- hammer recipes # list all
1178
- hammer recipes --install # interactive picker, then prints stub
1179
- hammer recipes --show NAME # cat the recipe source
1180
- hammer recipes --path NAME # absolute path
1181
- hammer recipes --edit NAME # open in $EDITOR (copies gem -> user dir first)
1182
- hammer recipes --run NAME [ARGS] # run without installing its bin
1183
- hammer recipes --run NAME -- --help # `--` forwards flags to the recipe
1180
+ hammer h:recipes # list all
1181
+ hammer h:recipes --install # interactive picker, then prints stub
1182
+ hammer h:recipes --show NAME # cat the recipe source
1183
+ hammer h:recipes --path NAME # absolute path
1184
+ hammer h:recipes --edit NAME # open in $EDITOR (copies gem -> user dir first)
1185
+ hammer h:recipes --run NAME [ARGS] # run without installing its bin
1186
+ hammer h:recipes --run NAME -- --help # `--` forwards flags to the recipe
1184
1187
  ```
1185
1188
 
1186
1189
  ## Programmatic use
@@ -1,29 +1,36 @@
1
1
  class Hammer
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.
2
+ # Built-in tasks of the `hammer` binary. Everything but :default lives
3
+ # under the reserved `h:` namespace (e.g. `hammer h:update`,
4
+ # `hammer h:recipes`) so the built-ins never collide with a project's
5
+ # own root tasks. Only the bare `hammer` invocation and the
6
+ # `-h`/`--help`/`help` request are handled at root - bare fires
7
+ # :default, which also carries the `--version` convenience flag.
13
8
  module Builtins
14
9
  module_function
15
10
 
16
- def register_core(klass)
17
- register_help(klass) unless klass.commands.key?('help')
11
+ # Single registration entry point - identical in every context
12
+ # (project Hammerfile or bare `hammer` binary). Namespacing under
13
+ # `h:` removes the old collision worries, so there's no core vs
14
+ # no-project split and no hidden-desc dance: the same tasks register
15
+ # everywhere, always dispatchable, and surface in the listing only
16
+ # under the extended `--help` view (the `@builtin_namespace` flag).
17
+ def register(klass)
18
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
19
 
24
- def register_no_project(klass)
25
- register_recipes(klass) unless klass.commands.key?('recipes')
26
- register_init(klass) unless klass.commands.key?('init')
20
+ existed = klass.namespaces.key?('h')
21
+ klass.namespace(:h) {} # ensure/reopen the reserved subclass
22
+ h = klass.namespaces['h']
23
+ # Flag the tree so the compact listing prunes it - the built-ins
24
+ # only show under the extended `--help` view (always dispatchable).
25
+ # Skip when the user already owns an `h:` namespace, so their own
26
+ # tasks stay visible in the bare listing.
27
+ h.instance_variable_set(:@builtin_namespace, true) unless existed
28
+ register_help(h) unless h.commands.key?('help')
29
+ register_update(h) unless h.commands.key?('update')
30
+ register_agents(h) unless h.commands.key?('agents')
31
+ register_version(h) unless h.commands.key?('version')
32
+ register_recipes(h) unless h.commands.key?('recipes')
33
+ register_init(h) unless h.commands.key?('init')
27
34
  end
28
35
 
29
36
  def register_help(klass)
@@ -37,9 +44,9 @@ class Hammer
37
44
  per-command help; with a namespace prefix prints that
38
45
  namespace's command listing.
39
46
  TXT
40
- example 'help'
41
- example 'help build'
42
- example 'help db:'
47
+ example 'h:help'
48
+ example 'h:help build'
49
+ example 'h:help db:'
43
50
  proc do |opts|
44
51
  self.class.root.print_help(opts[:args].first, extended: true)
45
52
  end
@@ -109,7 +116,7 @@ class Hammer
109
116
  # invocation lists; opts pick the action and positional args carry
110
117
  # the recipe name (and optional target path for --install). Run via
111
118
  # `--run NAME [ARGS]` - use `--` to forward flags to the recipe
112
- # itself (e.g. `hammer recipes --run srt -- --help`).
119
+ # itself (e.g. `hammer h:recipes --run srt -- --help`).
113
120
  def register_recipes(klass)
114
121
  klass.class_eval do
115
122
  task :recipes do
@@ -123,12 +130,12 @@ class Hammer
123
130
  opt :path, type: :boolean, desc: 'print recipe abs path'
124
131
  opt :edit, type: :boolean, desc: 'open recipe in $EDITOR (copies gem -> user dir first)'
125
132
  opt :run, type: :boolean, desc: 'run a recipe without installing its bin (forwards remaining args)'
126
- example 'recipes'
127
- example 'recipes --install srt ~/bin/srt # write + chmod in one shot'
128
- example 'recipes --install srt > ~/bin/srt && chmod +x $_'
129
- example 'recipes --show srt'
130
- example 'recipes --run srt extract movie.mp4'
131
- example 'recipes --run srt -- --help # -- forwards flags to the recipe'
133
+ example 'h:recipes'
134
+ example 'h:recipes --install srt ~/bin/srt # write + chmod in one shot'
135
+ example 'h:recipes --install srt > ~/bin/srt && chmod +x $_'
136
+ example 'h:recipes --show srt'
137
+ example 'h:recipes --run srt extract movie.mp4'
138
+ example 'h:recipes --run srt -- --help # -- forwards flags to the recipe'
132
139
  proc do |opts|
133
140
  args = opts[:args]
134
141
  if opts[:install]
@@ -170,7 +177,7 @@ class Hammer
170
177
 
171
178
  def require_name!(name, action)
172
179
  return name if name
173
- Shell.print_error "missing recipe name (usage: recipes --#{action} NAME)"
180
+ Shell.print_error "missing recipe name (usage: h:recipes --#{action} NAME)"
174
181
  exit 1
175
182
  end
176
183
 
@@ -192,7 +199,7 @@ class Hammer
192
199
  Shell.say "#{source}:", :yellow
193
200
  items.each_key do |name|
194
201
  _n, desc, installed = rows.find { |r| r.first == name }
195
- status = installed ? "(installed: #{installed})" : "[install: hammer recipes --install #{name}]"
202
+ status = installed ? "(installed: #{installed})" : "[install: hammer h:recipes --install #{name}]"
196
203
  line = " #{name.ljust(width)} # #{desc}"
197
204
  Shell.say line
198
205
  Shell.say " #{' ' * width} #{status}", :gray
@@ -242,7 +249,7 @@ class Hammer
242
249
  end
243
250
 
244
251
  # For a gem recipe, offer to copy to user dir first so edits
245
- # survive `hammer update`. Then exec $EDITOR on the file.
252
+ # survive `hammer h:update`. Then exec $EDITOR on the file.
246
253
  def edit(name)
247
254
  path = Hammer::Recipe.path(name) or fail_unknown(name)
248
255
  editor = ENV['EDITOR'] || ENV['VISUAL']
data/lib/hammer/dotenv.rb CHANGED
@@ -38,8 +38,9 @@ class Hammer
38
38
  key = key.strip
39
39
  next if key.empty?
40
40
  val = val.strip
41
- if (val.start_with?('"') && val.end_with?('"')) ||
42
- (val.start_with?("'") && val.end_with?("'"))
41
+ if val.length >= 2 &&
42
+ ((val.start_with?('"') && val.end_with?('"')) ||
43
+ (val.start_with?("'") && val.end_with?("'")))
43
44
  val = val[1..-2]
44
45
  end
45
46
  out[key] = val
data/lib/hammer/loader.rb CHANGED
@@ -38,31 +38,37 @@ class Hammer
38
38
 
39
39
  def resolve_pattern(anchor, pattern)
40
40
  abs = File.expand_path(pattern, anchor)
41
+ # A real file/dir wins over glob interpretation, so a literal path
42
+ # whose name contains [ ] { } * ? still resolves. Directory: discover
43
+ # *_hammer.rb under it (empty result is OK - an app may have none).
44
+ return [abs] if File.file?(abs)
45
+ return discover(abs) if File.directory?(abs)
41
46
  if abs.match?(/[\*\?\[\{]/)
42
47
  matches = Dir.glob(abs).sort
43
48
  raise Hammer::Error, "load: no files matched #{pattern.inspect}" if matches.empty?
44
49
  return matches
45
50
  end
46
- # Directory: discover *_hammer.rb under it. Empty result is OK
47
- # (an app may legitimately have no fragments).
48
- return discover(abs) if File.directory?(abs)
49
- return [abs] if File.file?(abs)
50
51
  raise Hammer::Error, "load: no files matched #{pattern.inspect}"
51
52
  end
52
53
 
53
54
  def discover(dir)
54
55
  return [] unless File.directory?(dir)
55
56
  out = []
56
- walk(dir, out)
57
+ walk(dir, out, {})
57
58
  out.sort
58
59
  end
59
60
 
60
- def walk(dir, out)
61
+ # `seen` tracks visited real paths so a cyclic directory symlink
62
+ # doesn't recurse forever.
63
+ def walk(dir, out, seen)
64
+ real = File.realpath(dir) rescue dir
65
+ return if seen[real]
66
+ seen[real] = true
61
67
  Dir.each_child(dir) do |entry|
62
68
  full = File.join(dir, entry)
63
69
  if File.directory?(full)
64
70
  next if SKIP_DIRS.include?(entry) || entry.start_with?('.')
65
- walk(full, out)
71
+ walk(full, out, seen)
66
72
  elsif entry.end_with?('_hammer.rb')
67
73
  out << full
68
74
  end
data/lib/hammer/option.rb CHANGED
@@ -59,6 +59,8 @@ class Hammer
59
59
  when :array then value.is_a?(Array) ? value : value.to_s.split(',')
60
60
  else value.to_s
61
61
  end
62
+ rescue ArgumentError, TypeError
63
+ raise Hammer::Parser::Error, "invalid #{type} value for --#{name}: #{value.inspect}"
62
64
  end
63
65
 
64
66
  def usage
data/lib/hammer/parser.rb CHANGED
@@ -8,9 +8,9 @@ class Hammer
8
8
  @options = options
9
9
  @by_switch = {}
10
10
  options.each do |opt|
11
- @by_switch[opt.switch] = opt
12
- @by_switch[opt.negation] = opt if opt.boolean?
13
- opt.aliases.each { |a| @by_switch[a] = opt }
11
+ register_switch(opt.switch, opt)
12
+ register_switch(opt.negation, opt) if opt.boolean?
13
+ opt.aliases.each { |a| register_switch(a, opt) }
14
14
  end
15
15
  end
16
16
 
@@ -31,7 +31,11 @@ class Hammer
31
31
  if token.start_with?('--') && token.include?('=')
32
32
  key, val = token.split('=', 2)
33
33
  opt = lookup!(key)
34
- values[opt.name] = opt.cast(val)
34
+ if opt.boolean? && key.start_with?('--no-')
35
+ values[opt.name] = !opt.cast(val) # `--no-x=false` -> true
36
+ else
37
+ values[opt.name] = opt.cast(val)
38
+ end
35
39
  i += 1
36
40
  next
37
41
  end
@@ -50,7 +54,18 @@ class Hammer
50
54
  next
51
55
  end
52
56
 
53
- if token.start_with?('-') && token.length > 1
57
+ # Glued short flag with value: `-pVALUE` (non-boolean short opts only).
58
+ if token.start_with?('-') && !token.start_with?('--') && token.length > 2
59
+ if (opt = @by_switch[token[0, 2]]) && !opt.boolean?
60
+ values[opt.name] = opt.cast(token[2..])
61
+ i += 1
62
+ next
63
+ end
64
+ end
65
+
66
+ # Dash-led and not a negative number -> a genuinely unknown flag.
67
+ # Bare `-` and negative numbers (`-5`) fall through to positionals.
68
+ if token.start_with?('-') && token.length > 1 && token !~ /\A-\d/
54
69
  raise Error, "unknown option: #{token}"
55
70
  end
56
71
 
@@ -59,15 +74,21 @@ class Hammer
59
74
  end
60
75
 
61
76
  # Fill un-set non-boolean opts from positional args in declaration
62
- # order. Booleans always need an explicit flag.
77
+ # order. Booleans always need an explicit flag. Scalars take one
78
+ # positional each; an :array opt slurps whatever's left, so it's
79
+ # filled last and never starves a later-declared scalar opt.
80
+ array_opt = nil
63
81
  @options.each do |opt|
64
- break if positional.empty?
65
82
  next if opt.boolean? || values.key?(opt.name)
66
83
  if opt.type == :array
67
- values[opt.name] = opt.cast(positional.shift(positional.size))
68
- else
69
- values[opt.name] = opt.cast(positional.shift)
84
+ array_opt = opt
85
+ next
70
86
  end
87
+ break if positional.empty?
88
+ values[opt.name] = opt.cast(positional.shift)
89
+ end
90
+ if array_opt && !positional.empty?
91
+ values[array_opt.name] = array_opt.cast(positional.shift(positional.size))
71
92
  end
72
93
 
73
94
  @options.each do |opt|
@@ -80,6 +101,15 @@ class Hammer
80
101
 
81
102
  private
82
103
 
104
+ # Map a flag string to its option, refusing silent shadowing when two
105
+ # options would claim the same switch/negation/alias.
106
+ def register_switch(flag, opt)
107
+ if (prev = @by_switch[flag]) && prev != opt
108
+ raise Error, "flag #{flag} claimed by both :#{prev.name} and :#{opt.name}"
109
+ end
110
+ @by_switch[flag] = opt
111
+ end
112
+
83
113
  def lookup!(key)
84
114
  @by_switch[key] or raise Error, "unknown option: #{key}"
85
115
  end
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 recipes --install`. User
52
+ # Ruby wrapper text printed by `hammer h: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 recipes --install #{name} > ~/bin/#{name} && chmod +x $_
59
+ # install: hammer h:recipes --install #{name} > ~/bin/#{name} && chmod +x $_
60
60
  require 'lux-hammer'
61
61
  Hammer.recipe('#{name}', ARGV)
62
62
  RUBY
data/lib/hammer/shell.rb CHANGED
@@ -13,8 +13,11 @@ class Hammer
13
13
  module_function
14
14
 
15
15
  def color?
16
- return @color if defined?(@color)
17
- @color = $stdout.tty? && ENV['NO_COLOR'].nil?
16
+ # Only an explicit color!(value) override is sticky; otherwise the
17
+ # tty decision is recomputed so a redirected $stdout (tests, capture
18
+ # blocks) is honored instead of frozen at first read.
19
+ return @color if defined?(@color) && !@color.nil?
20
+ $stdout.tty? && ENV['NO_COLOR'].nil?
18
21
  end
19
22
 
20
23
  def color!(value)