lux-hammer 0.2.5 → 0.2.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 +23 -0
- data/README.md +16 -6
- data/lib/hammer/builder.rb +33 -0
- data/lib/hammer/dotenv.rb +50 -0
- data/lib/hammer/loader.rb +1 -1
- data/lib/hammer/option.rb +12 -9
- data/lib/lux-hammer.rb +175 -47
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d5fc940f903d901587d17cd94c6f34eed5f92de64ded400a882228e34037cad1
|
|
4
|
+
data.tar.gz: 480bd5d42e755ea6c92d956e48eb146777718e6be46a74042b9c8f0192479c27
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 33bf95c45d0eba2bf3d37aa55f7dcb6dcf317c25b3badeaba965f00f582c27cb92285db07f360e290c3c8d41f82f575bbb6171583bd5cce106b065843f7ad08d
|
|
7
|
+
data.tar.gz: 2dec31a7d86c8a3f525fa2beac4134576a634141001550b9226eed0ed723f6768a56bf404be4fbdf239a954eccb094be060695838a7c25cd820aae72af7db186
|
data/.version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.2.
|
|
1
|
+
0.2.7
|
data/AGENTS.md
CHANGED
|
@@ -84,6 +84,12 @@ At class or `Hammerfile` scope:
|
|
|
84
84
|
|
|
85
85
|
* `task :name do ... end`
|
|
86
86
|
* `namespace :name do ... end`
|
|
87
|
+
* `dotenv false` - opt out of auto `.env` / `.env.local` loading.
|
|
88
|
+
Only meaningful from the `hammer` binary (`Hammer.cli`); the load
|
|
89
|
+
happens after Hammerfile evaluation, before dispatch. Shell-set vars
|
|
90
|
+
always win (`ENV[k] ||= v`). For full dotenv-gem features
|
|
91
|
+
(interpolation, multiline) keep `dotenv false` and call `Dotenv.load`
|
|
92
|
+
from a `before` hook instead.
|
|
87
93
|
* `load` / `load auto: true` / `load 'path/file.rb'` / `load 'glob/*.rb'` /
|
|
88
94
|
`load 'some/dir'` - pull in Hammerfile fragments from `*_hammer.rb`
|
|
89
95
|
files. Paths resolve relative to the caller's file. A directory
|
|
@@ -140,6 +146,23 @@ explicit ADR-level discussion. Keys:
|
|
|
140
146
|
* The root `Hammer` subclass holds `@commands` and `@namespaces`.
|
|
141
147
|
* `resolve('a:b:c')` walks `@namespaces['a'].namespaces['b']` and
|
|
142
148
|
finds command `c` in the last class.
|
|
149
|
+
* Each segment is fuzzy-matched: exact name/alt first, then prefix
|
|
150
|
+
(`b` -> `build`), then substring (`es` -> `test`). Per-scope: matching
|
|
151
|
+
only looks at this class's commands/namespaces, not globally across
|
|
152
|
+
the tree. Ambiguous fuzzy matches raise `Hammer::AmbiguousMatch`,
|
|
153
|
+
which `dispatch` catches and prints as
|
|
154
|
+
`multiple commands match 'b': bake, build`. Same fallback applies to
|
|
155
|
+
namespaces via `find_namespace`.
|
|
156
|
+
* `resolve` and `resolve_namespace` return a `canonical` colon-path
|
|
157
|
+
alongside the resolved class/command, so help banners and the
|
|
158
|
+
pre-run banner display the canonical name even when the user typed
|
|
159
|
+
a fuzzy prefix.
|
|
160
|
+
* Before a command's handler runs, `run_command` prints a gray
|
|
161
|
+
`> prog cmd --opt=val ARG` banner so it's visible which command was
|
|
162
|
+
actually picked when fuzzy matching kicks in. Only options that
|
|
163
|
+
differ from their default are listed; booleans render as `--flag`
|
|
164
|
+
/ `--no-flag`. Help paths (`-h`, bare namespace) short-circuit
|
|
165
|
+
before the banner.
|
|
143
166
|
* There is **no per-level dispatch**. A namespace is a container, not a
|
|
144
167
|
CLI of its own. Do not reintroduce `subclass.start(remaining_argv)`.
|
|
145
168
|
* `start(argv)` is a two-step pipeline: `split_chain(argv)` (private)
|
data/README.md
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
# hammer
|
|
2
2
|
|
|
3
|
-
The bastard Frankenstein child of Rake,
|
|
4
|
-
|
|
3
|
+
The bastard Frankenstein child of Rake](https://github.com/ruby/rake),
|
|
4
|
+
[Thor](https://github.com/rails/thor), and [Joshua](https://github.com/dux/joshua).
|
|
5
|
+
Sewn together from three good ideas, with the rest of each parent left on
|
|
5
6
|
the cutting room floor.
|
|
6
7
|
|
|
7
8
|
Drop a `Hammerfile`, run `hammer`, ship. AI LLM-s love `hammer`.
|
|
8
9
|
|
|
9
10
|
```ruby
|
|
10
|
-
namespace :db do
|
|
11
|
-
task :migrate do
|
|
11
|
+
namespace :db do # Rake-style colon paths
|
|
12
|
+
task :migrate do # Joshua-style task block
|
|
12
13
|
desc 'Run pending migrations'
|
|
13
|
-
opt :pretend, type: :boolean, alias: :p
|
|
14
|
+
opt :pretend, type: :boolean, alias: :p # Thor-style typed opts
|
|
14
15
|
proc do |o|
|
|
15
16
|
say.green "migrating pretend=#{o[:pretend].inspect}"
|
|
16
17
|
end
|
|
@@ -468,7 +469,7 @@ runs before every command in that scope (and its nested namespaces).
|
|
|
468
469
|
Hooks fire outer -> inner, then the command's handler:
|
|
469
470
|
|
|
470
471
|
```ruby
|
|
471
|
-
before {
|
|
472
|
+
before { hammer :env } # runs before every command
|
|
472
473
|
|
|
473
474
|
namespace :db do
|
|
474
475
|
before { hammer :env } # runs before every db:* command
|
|
@@ -478,6 +479,15 @@ namespace :db do
|
|
|
478
479
|
end
|
|
479
480
|
```
|
|
480
481
|
|
|
482
|
+
`.env` and `.env.local` next to the `Hammerfile` are loaded
|
|
483
|
+
automatically by the `hammer` binary - no `before` hook needed.
|
|
484
|
+
Shell-set vars are never overwritten, and `.env.local` overrides
|
|
485
|
+
`.env`. Put `dotenv false` at the top of the Hammerfile to opt out:
|
|
486
|
+
|
|
487
|
+
```ruby
|
|
488
|
+
dotenv false # disable auto .env loading
|
|
489
|
+
```
|
|
490
|
+
|
|
481
491
|
`before` is intentionally not available inside `task` - the proc body
|
|
482
492
|
*is* the command body, just put the setup line at the top of the proc.
|
|
483
493
|
|
data/lib/hammer/builder.rb
CHANGED
|
@@ -21,6 +21,11 @@ class Hammer
|
|
|
21
21
|
@klass.before(&block)
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
+
# Opt out of auto `.env` loading in `Hammer.cli`. Default is on.
|
|
25
|
+
def dotenv(flag = true)
|
|
26
|
+
@klass.dotenv(flag)
|
|
27
|
+
end
|
|
28
|
+
|
|
24
29
|
# Same surface as `Hammer.load`. Resolved relative to the file that
|
|
25
30
|
# called us, so `load auto: true` inside a Hammerfile picks up
|
|
26
31
|
# *_hammer.rb under the Hammerfile's directory.
|
|
@@ -28,5 +33,33 @@ class Hammer
|
|
|
28
33
|
anchor = Loader.caller_anchor(caller_locations(1, 1).first)
|
|
29
34
|
@klass.loader.load(anchor, paths, kwargs)
|
|
30
35
|
end
|
|
36
|
+
|
|
37
|
+
# instance_eval wrapper that also publishes the current Hammer target
|
|
38
|
+
# via Thread.current[:hammer_target]. That lets `*_hammer.rb` files
|
|
39
|
+
# pulled in through Ruby's own `require` (e.g. `Dir.require_all
|
|
40
|
+
# 'lib/tasks'` inside a Hammerfile) call `task`/`namespace`/`before`
|
|
41
|
+
# at top-level scope and have them register on the right target.
|
|
42
|
+
def evaluate(source = nil, path = nil, &block)
|
|
43
|
+
Hammer.with_target(@klass) do
|
|
44
|
+
block ? instance_eval(&block) : instance_eval(source, path)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Top-level Hammer DSL. Extended onto `main` (below) so any file
|
|
50
|
+
# `require`d from within a Hammerfile - where `self == main` - can
|
|
51
|
+
# still call `task`, `namespace`, and `before`. Delegates to whichever
|
|
52
|
+
# Hammer subclass is currently being evaluated.
|
|
53
|
+
module DSL
|
|
54
|
+
%i[task namespace before dotenv].each do |m|
|
|
55
|
+
define_method(m) do |*args, &block|
|
|
56
|
+
target = Thread.current[:hammer_target]
|
|
57
|
+
raise Hammer::Error, "`#{m}` called outside a Hammer context " \
|
|
58
|
+
'(Hammerfile / Hammer.run block / *_hammer.rb)' unless target
|
|
59
|
+
target.send(m, *args, &block)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
31
62
|
end
|
|
32
63
|
end
|
|
64
|
+
|
|
65
|
+
TOPLEVEL_BINDING.eval('self').extend(Hammer::DSL)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
class Hammer
|
|
2
|
+
# Tiny built-in .env loader. Kept dependency-free on purpose - the
|
|
3
|
+
# gemspec advertises zero runtime deps.
|
|
4
|
+
#
|
|
5
|
+
# `Hammer.cli` calls `Dotenv.load(Dir.pwd)` after evaluating the
|
|
6
|
+
# Hammerfile (unless the Hammerfile said `dotenv false`), so vars
|
|
7
|
+
# from `.env` are available to every command handler.
|
|
8
|
+
#
|
|
9
|
+
# Semantics:
|
|
10
|
+
# * `.env` is loaded, then `.env.local` (latter overrides former).
|
|
11
|
+
# * Missing files are silently skipped - auto-load must not raise.
|
|
12
|
+
# * Existing `ENV[k]` always wins (`ENV[k] ||= v`); shell-set values
|
|
13
|
+
# are never clobbered.
|
|
14
|
+
# * Supported lines: `KEY=value`, `KEY="value"`, `KEY='value'`, optional
|
|
15
|
+
# leading `export `, `#` comments, blank lines.
|
|
16
|
+
# * NOT supported: `${VAR}` interpolation, multiline values, inline
|
|
17
|
+
# comments after a value. Reach for the `dotenv` gem from a `before`
|
|
18
|
+
# hook if you need those.
|
|
19
|
+
module Dotenv
|
|
20
|
+
FILES ||= %w[.env .env.local].freeze
|
|
21
|
+
|
|
22
|
+
def self.load(dir)
|
|
23
|
+
FILES.each do |name|
|
|
24
|
+
path = File.join(dir, name)
|
|
25
|
+
next unless File.file?(path)
|
|
26
|
+
parse(File.read(path)).each { |k, v| ENV[k] ||= v }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.parse(text)
|
|
31
|
+
out = {}
|
|
32
|
+
text.each_line do |line|
|
|
33
|
+
line = line.strip
|
|
34
|
+
next if line.empty? || line.start_with?('#')
|
|
35
|
+
line = line.sub(/\Aexport\s+/, '')
|
|
36
|
+
key, sep, val = line.partition('=')
|
|
37
|
+
next if sep.empty?
|
|
38
|
+
key = key.strip
|
|
39
|
+
next if key.empty?
|
|
40
|
+
val = val.strip
|
|
41
|
+
if (val.start_with?('"') && val.end_with?('"')) ||
|
|
42
|
+
(val.start_with?("'") && val.end_with?("'"))
|
|
43
|
+
val = val[1..-2]
|
|
44
|
+
end
|
|
45
|
+
out[key] = val
|
|
46
|
+
end
|
|
47
|
+
out
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
data/lib/hammer/loader.rb
CHANGED
|
@@ -72,7 +72,7 @@ class Hammer
|
|
|
72
72
|
def load_one(abs_path)
|
|
73
73
|
return if @loaded[abs_path]
|
|
74
74
|
@loaded[abs_path] = true
|
|
75
|
-
Builder.new(@target).
|
|
75
|
+
Builder.new(@target).evaluate(File.read(abs_path), abs_path)
|
|
76
76
|
rescue Hammer::Error
|
|
77
77
|
raise
|
|
78
78
|
rescue StandardError => e
|
data/lib/hammer/option.rb
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
class Hammer
|
|
2
2
|
# Single option/flag definition.
|
|
3
3
|
class Option
|
|
4
|
-
attr_reader :name, :type, :default, :desc, :aliases, :required
|
|
4
|
+
attr_reader :name, :type, :default, :desc, :aliases, :required, :placeholder
|
|
5
5
|
|
|
6
|
-
ALLOWED_KEYS ||= %i[type default desc alias req].freeze
|
|
6
|
+
ALLOWED_KEYS ||= %i[type default desc alias req placeholder].freeze
|
|
7
7
|
RESERVED_FLAGS ||= %w[-h --help].freeze
|
|
8
8
|
|
|
9
9
|
def initialize(name, **opts)
|
|
@@ -13,12 +13,15 @@ class Hammer
|
|
|
13
13
|
"unknown opt parameter(s) for :#{name}: #{bad.join(', ')}. " \
|
|
14
14
|
"allowed: #{ALLOWED_KEYS.join(', ')}"
|
|
15
15
|
end
|
|
16
|
-
@name
|
|
17
|
-
@type
|
|
18
|
-
@default
|
|
19
|
-
@desc
|
|
20
|
-
@aliases
|
|
21
|
-
@required
|
|
16
|
+
@name = name.to_sym
|
|
17
|
+
@type = opts[:type] || :string
|
|
18
|
+
@default = opts[:default]
|
|
19
|
+
@desc = opts[:desc]
|
|
20
|
+
@aliases = Array(opts[:alias]).map { |a| Option.normalize_alias(a) }
|
|
21
|
+
@required = opts[:req] ? true : false
|
|
22
|
+
# Custom usage placeholder, e.g. `placeholder: 't/f'` -> `--log t/f`.
|
|
23
|
+
# Falls back to uppercased name (`--log LOG`) when nil.
|
|
24
|
+
@placeholder = opts[:placeholder]
|
|
22
25
|
|
|
23
26
|
# Reserve -h / --help so every command supports them uniformly.
|
|
24
27
|
if RESERVED_FLAGS.include?(switch)
|
|
@@ -60,7 +63,7 @@ class Hammer
|
|
|
60
63
|
|
|
61
64
|
def usage
|
|
62
65
|
flag = switch
|
|
63
|
-
flag += " #{name.to_s.upcase}" unless boolean?
|
|
66
|
+
flag += " #{placeholder || name.to_s.upcase}" unless boolean?
|
|
64
67
|
line = aliases.empty? ? flag : "#{aliases.join(', ')}, #{flag}"
|
|
65
68
|
suffix = annotation
|
|
66
69
|
line = "#{line.ljust(28)} #{desc}" if desc
|
data/lib/lux-hammer.rb
CHANGED
|
@@ -5,6 +5,7 @@ require_relative 'hammer/command'
|
|
|
5
5
|
require_relative 'hammer/loader'
|
|
6
6
|
require_relative 'hammer/builder'
|
|
7
7
|
require_relative 'hammer/command_builder'
|
|
8
|
+
require_relative 'hammer/dotenv'
|
|
8
9
|
|
|
9
10
|
# Thor-inspired tiny CLI builder.
|
|
10
11
|
#
|
|
@@ -40,7 +41,11 @@ require_relative 'hammer/command_builder'
|
|
|
40
41
|
class Hammer
|
|
41
42
|
include Shell
|
|
42
43
|
|
|
43
|
-
Error
|
|
44
|
+
Error ||= Class.new(StandardError)
|
|
45
|
+
AmbiguousMatch ||= Class.new(Error)
|
|
46
|
+
|
|
47
|
+
# Gem version, read once from the bundled .version file.
|
|
48
|
+
VERSION ||= File.read(File.expand_path('../.version', __dir__)).strip
|
|
44
49
|
|
|
45
50
|
class << self
|
|
46
51
|
def inherited(sub)
|
|
@@ -178,7 +183,7 @@ class Hammer
|
|
|
178
183
|
# "myapp ns:cmd" with the same prefix everywhere - and so the value
|
|
179
184
|
# captured pre-chdir (see `Hammer.cli`) survives into nested classes.
|
|
180
185
|
sub.instance_variable_set(:@program_name, program_name)
|
|
181
|
-
sub.class_eval(&block) if block
|
|
186
|
+
Hammer.with_target(sub) { sub.class_eval(&block) } if block
|
|
182
187
|
@namespaces[name.to_s] = sub
|
|
183
188
|
end
|
|
184
189
|
|
|
@@ -199,6 +204,18 @@ class Hammer
|
|
|
199
204
|
@before_hooks
|
|
200
205
|
end
|
|
201
206
|
|
|
207
|
+
# Toggle auto-loading of `.env` / `.env.local` for the `hammer`
|
|
208
|
+
# binary. Default is ON. Call `dotenv false` at the top of a
|
|
209
|
+
# Hammerfile to suppress. No-op for standalone `MyCli.start` -
|
|
210
|
+
# auto-load only fires from `Hammer.cli`.
|
|
211
|
+
def dotenv(flag = true)
|
|
212
|
+
@dotenv_enabled = flag
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def dotenv_enabled?
|
|
216
|
+
@dotenv_enabled != false
|
|
217
|
+
end
|
|
218
|
+
|
|
202
219
|
def parent
|
|
203
220
|
@parent
|
|
204
221
|
end
|
|
@@ -235,6 +252,18 @@ class Hammer
|
|
|
235
252
|
@loader ||= Loader.new(self)
|
|
236
253
|
end
|
|
237
254
|
|
|
255
|
+
# Push `klass` as the current Hammer target for the duration of the
|
|
256
|
+
# block. Top-level DSL methods (`task`, `namespace`, `before` - see
|
|
257
|
+
# `Hammer::DSL`) read this thread-local, so files `require`d from
|
|
258
|
+
# inside a Hammerfile register against the right target.
|
|
259
|
+
def with_target(klass)
|
|
260
|
+
prev = Thread.current[:hammer_target]
|
|
261
|
+
Thread.current[:hammer_target] = klass
|
|
262
|
+
yield
|
|
263
|
+
ensure
|
|
264
|
+
Thread.current[:hammer_target] = prev
|
|
265
|
+
end
|
|
266
|
+
|
|
238
267
|
# Load Hammerfile fragments and register their commands on this
|
|
239
268
|
# class. Rake-style: split a CLI across multiple files.
|
|
240
269
|
#
|
|
@@ -295,60 +324,119 @@ class Hammer
|
|
|
295
324
|
argv = argv.dup
|
|
296
325
|
name = argv.shift
|
|
297
326
|
|
|
298
|
-
if name.nil?
|
|
327
|
+
if name.nil?
|
|
328
|
+
# Bare invocation of the `hammer` binary: print a gem banner
|
|
329
|
+
# above the help so users can see which lux-hammer they have.
|
|
330
|
+
# Skipped for `-h` / `help` (those stay terse) and for user-
|
|
331
|
+
# built CLIs (their own program name, not lux-hammer).
|
|
332
|
+
if root.instance_variable_get(:@hammer_binary)
|
|
333
|
+
Shell.say "lux-hammer #{VERSION}"
|
|
334
|
+
Shell.say ''
|
|
335
|
+
end
|
|
336
|
+
return print_help
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
if name == 'help' || name == '-h' || name == '--help'
|
|
299
340
|
target = argv.shift
|
|
300
341
|
return print_help(target)
|
|
301
342
|
end
|
|
302
343
|
|
|
303
|
-
# Trailing colon ("db:") ->
|
|
304
|
-
# per-command help on every task. Bare ":" expands the root.
|
|
344
|
+
# Trailing colon ("db:") -> namespace listing. Bare ":" lists root.
|
|
305
345
|
if name.end_with?(':') && name != ':'
|
|
306
346
|
bare = name.chomp(':')
|
|
307
|
-
ns = resolve_namespace(bare)
|
|
308
|
-
return print_namespace_help(
|
|
347
|
+
ns, canonical = resolve_namespace(bare)
|
|
348
|
+
return print_namespace_help(canonical, ns) if ns
|
|
309
349
|
Shell.print_error("unknown namespace: #{bare}")
|
|
310
350
|
print_help
|
|
311
351
|
exit 1
|
|
312
352
|
elsif name == ':'
|
|
313
|
-
return print_help
|
|
353
|
+
return print_help
|
|
314
354
|
end
|
|
315
355
|
|
|
316
|
-
cmd, owner = resolve(name)
|
|
317
|
-
return owner.run_command(cmd, argv, full:
|
|
356
|
+
cmd, owner, canonical = resolve(name)
|
|
357
|
+
return owner.run_command(cmd, argv, full: canonical) if cmd
|
|
318
358
|
|
|
319
|
-
ns = resolve_namespace(name)
|
|
320
|
-
return print_namespace_help(
|
|
359
|
+
ns, canonical = resolve_namespace(name)
|
|
360
|
+
return print_namespace_help(canonical, ns) if ns
|
|
321
361
|
|
|
322
362
|
Shell.print_error("unknown command: #{name}")
|
|
323
363
|
print_help
|
|
324
364
|
exit 1
|
|
365
|
+
rescue AmbiguousMatch => e
|
|
366
|
+
Shell.print_error(e.message)
|
|
367
|
+
exit 1
|
|
325
368
|
end
|
|
326
369
|
|
|
327
370
|
public
|
|
328
371
|
|
|
329
|
-
# Find a command by canonical name or alt within this class.
|
|
372
|
+
# Find a command by canonical name or alt within this class. Falls
|
|
373
|
+
# back to fuzzy match (prefix first, then substring) when no exact
|
|
374
|
+
# hit. Raises AmbiguousMatch if the fuzzy pass matches more than one.
|
|
330
375
|
def find_command(name)
|
|
331
|
-
|
|
376
|
+
name = name.to_s
|
|
377
|
+
exact = commands[name] || commands.values.find { |c| c.matches?(name) }
|
|
378
|
+
return exact if exact
|
|
379
|
+
fuzzy_pick(name, commands.values, 'command') { |c| [c.name, *c.alts] }
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# Find a namespace by name within this class. Same fuzzy fallback
|
|
383
|
+
# as find_command.
|
|
384
|
+
def find_namespace(name)
|
|
385
|
+
name = name.to_s
|
|
386
|
+
return namespaces[name] if namespaces.key?(name)
|
|
387
|
+
pair = fuzzy_pick(name, namespaces.to_a, 'namespace') { |p| [p.first] }
|
|
388
|
+
pair&.last
|
|
332
389
|
end
|
|
333
390
|
|
|
334
|
-
# Walk "ns1:ns2:cmd" -> [command, owning_class
|
|
335
|
-
# if any segment is missing or the final
|
|
391
|
+
# Walk "ns1:ns2:cmd" -> [command, owning_class, canonical_path].
|
|
392
|
+
# Returns [nil, nil, nil] if any segment is missing or the final
|
|
393
|
+
# segment isn't a command. canonical_path uses the canonical name
|
|
394
|
+
# of every segment (so a fuzzy `b` resolves to `build` in the path).
|
|
336
395
|
def resolve(path)
|
|
337
396
|
parts = path.to_s.split(':')
|
|
338
397
|
klass = self
|
|
398
|
+
canonical = []
|
|
339
399
|
parts[0..-2].each do |ns|
|
|
340
|
-
|
|
400
|
+
sub = klass.find_namespace(ns) or return [nil, nil, nil]
|
|
401
|
+
canonical << klass.namespaces.key(sub)
|
|
402
|
+
klass = sub
|
|
341
403
|
end
|
|
342
404
|
cmd = klass.find_command(parts.last)
|
|
343
|
-
|
|
405
|
+
return [nil, nil, nil] unless cmd
|
|
406
|
+
canonical << cmd.name
|
|
407
|
+
[cmd, klass, canonical.join(':')]
|
|
344
408
|
end
|
|
345
409
|
|
|
346
|
-
# Walk "ns1:ns2" ->
|
|
410
|
+
# Walk "ns1:ns2" -> [namespace_class, canonical_path].
|
|
411
|
+
# Returns [nil, nil] if any segment is missing.
|
|
347
412
|
def resolve_namespace(path)
|
|
348
413
|
parts = path.to_s.split(':')
|
|
349
414
|
klass = self
|
|
350
|
-
|
|
351
|
-
|
|
415
|
+
canonical = []
|
|
416
|
+
parts.each do |ns|
|
|
417
|
+
sub = klass.find_namespace(ns) or return [nil, nil]
|
|
418
|
+
canonical << klass.namespaces.key(sub)
|
|
419
|
+
klass = sub
|
|
420
|
+
end
|
|
421
|
+
[klass, canonical.join(':')]
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# Shared fuzzy matcher used by find_command and find_namespace.
|
|
425
|
+
# The block returns the strings to match against for each item
|
|
426
|
+
# (canonical name plus alts for commands, just the key for namespaces).
|
|
427
|
+
# Tries prefix match first, then substring; raises AmbiguousMatch
|
|
428
|
+
# when either pass hits more than one item.
|
|
429
|
+
def fuzzy_pick(name, items, kind, &keys_for)
|
|
430
|
+
[:start_with?, :include?].each do |op|
|
|
431
|
+
matches = items.select { |item| keys_for.call(item).any? { |k| k.send(op, name) } }
|
|
432
|
+
next if matches.empty?
|
|
433
|
+
if matches.size > 1
|
|
434
|
+
labels = matches.map { |m| keys_for.call(m).first }.sort
|
|
435
|
+
raise AmbiguousMatch, "multiple #{kind}s match '#{name}': #{labels.join(', ')}"
|
|
436
|
+
end
|
|
437
|
+
return matches.first
|
|
438
|
+
end
|
|
439
|
+
nil
|
|
352
440
|
end
|
|
353
441
|
|
|
354
442
|
# Programmatic dispatch by name. Useful for scripting and tests.
|
|
@@ -394,6 +482,7 @@ class Hammer
|
|
|
394
482
|
|
|
395
483
|
positional, opts = Parser.new(cmd.options).parse(argv)
|
|
396
484
|
opts[:args] = positional
|
|
485
|
+
print_run_banner(cmd, full || cmd.name, positional, opts)
|
|
397
486
|
instance = new
|
|
398
487
|
run_before_hooks(instance, opts)
|
|
399
488
|
run_needs(cmd)
|
|
@@ -409,6 +498,25 @@ class Hammer
|
|
|
409
498
|
exit 1
|
|
410
499
|
end
|
|
411
500
|
|
|
501
|
+
# Print a gray "> prog cmd --opt=val ARG" banner before a command
|
|
502
|
+
# runs. Helps see what was actually picked when fuzzy matching
|
|
503
|
+
# resolved a partial name. Only opts that differ from their default
|
|
504
|
+
# are shown; booleans render as `--flag` / `--no-flag`.
|
|
505
|
+
def print_run_banner(cmd, full, positional, opts)
|
|
506
|
+
parts = ["#{program_name} #{full}"]
|
|
507
|
+
cmd.options.each do |o|
|
|
508
|
+
val = opts[o.name]
|
|
509
|
+
next if val.nil? || val == o.default
|
|
510
|
+
if o.boolean?
|
|
511
|
+
parts << (val ? "--#{o.name}" : "--no-#{o.name}")
|
|
512
|
+
else
|
|
513
|
+
parts << "--#{o.name}=#{val}"
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
parts.concat(positional)
|
|
517
|
+
Shell.say "> #{parts.join(' ')}", :gray
|
|
518
|
+
end
|
|
519
|
+
|
|
412
520
|
# Fire `before` hooks from root down through the namespace chain.
|
|
413
521
|
# Each class's hooks fire at most once per top-level `start`, so
|
|
414
522
|
# prereqs dispatched via `needs` won't re-trigger them.
|
|
@@ -444,18 +552,18 @@ class Hammer
|
|
|
444
552
|
|
|
445
553
|
def print_help(target = nil, full: false)
|
|
446
554
|
if target
|
|
447
|
-
# `help ns:` is equivalent to `ns:` -
|
|
555
|
+
# `help ns:` is equivalent to `ns:` - namespace listing.
|
|
448
556
|
if target.end_with?(':') && target != ':'
|
|
449
557
|
bare = target.chomp(':')
|
|
450
|
-
ns = resolve_namespace(bare)
|
|
451
|
-
return print_namespace_help(
|
|
558
|
+
ns, canonical = resolve_namespace(bare)
|
|
559
|
+
return print_namespace_help(canonical, ns) if ns
|
|
452
560
|
Shell.print_error("unknown: #{target}")
|
|
453
561
|
return
|
|
454
562
|
end
|
|
455
|
-
cmd, _ = resolve(target)
|
|
456
|
-
return print_command_help(cmd,
|
|
457
|
-
ns = resolve_namespace(target)
|
|
458
|
-
return print_namespace_help(
|
|
563
|
+
cmd, _, canonical = resolve(target)
|
|
564
|
+
return print_command_help(cmd, canonical) if cmd
|
|
565
|
+
ns, canonical = resolve_namespace(target)
|
|
566
|
+
return print_namespace_help(canonical, ns, full: full) if ns
|
|
459
567
|
Shell.print_error("unknown: #{target}")
|
|
460
568
|
return
|
|
461
569
|
end
|
|
@@ -467,6 +575,7 @@ class Hammer
|
|
|
467
575
|
Shell.say ''
|
|
468
576
|
print_command_list(self)
|
|
469
577
|
end
|
|
578
|
+
print_global_flags
|
|
470
579
|
print_footer
|
|
471
580
|
end
|
|
472
581
|
|
|
@@ -478,6 +587,7 @@ class Hammer
|
|
|
478
587
|
Shell.say ''
|
|
479
588
|
print_command_list(ns, prefix)
|
|
480
589
|
end
|
|
590
|
+
print_global_flags
|
|
481
591
|
print_footer
|
|
482
592
|
end
|
|
483
593
|
|
|
@@ -490,6 +600,16 @@ class Hammer
|
|
|
490
600
|
|
|
491
601
|
HOMEPAGE ||= 'https://github.com/dux/hammer'.freeze
|
|
492
602
|
|
|
603
|
+
# Global flags only exist when invoked via the `hammer` binary
|
|
604
|
+
# (see `Hammer.cli`), not for user-built CLIs that call `start`
|
|
605
|
+
# on their own subclass.
|
|
606
|
+
def print_global_flags
|
|
607
|
+
return unless root.instance_variable_get(:@hammer_binary)
|
|
608
|
+
Shell.say ''
|
|
609
|
+
Shell.say 'Global:', :yellow
|
|
610
|
+
Shell.say ' --ai # Print AGENTS.md (AI-friendly Hammerfile authoring docs)'
|
|
611
|
+
end
|
|
612
|
+
|
|
493
613
|
def print_footer
|
|
494
614
|
Shell.say ''
|
|
495
615
|
Shell.say "powered by hammer - #{HOMEPAGE}", :gray
|
|
@@ -505,13 +625,13 @@ class Hammer
|
|
|
505
625
|
|
|
506
626
|
# group by "section" = everything between the view prefix and the
|
|
507
627
|
# leaf name. Bare leaves go in :root.
|
|
508
|
-
groups = rows.group_by { |full, _| section_for(full, prefix) }
|
|
509
|
-
width = rows.map { |full,
|
|
628
|
+
groups = rows.group_by { |full, _| section_for(full, prefix, klass) }
|
|
629
|
+
width = rows.map { |full, _| full.length }.max
|
|
510
630
|
first = true
|
|
511
631
|
|
|
512
632
|
if (rooted = groups.delete(:root))
|
|
513
633
|
Shell.say 'Commands:', :yellow
|
|
514
|
-
emit_rows(rooted.sort_by { |full, _| full }, width)
|
|
634
|
+
emit_rows(rooted.sort_by { |full, _| [full.count(':'), full] }, width)
|
|
515
635
|
first = false
|
|
516
636
|
end
|
|
517
637
|
|
|
@@ -519,14 +639,14 @@ class Hammer
|
|
|
519
639
|
Shell.say unless first
|
|
520
640
|
first = false
|
|
521
641
|
Shell.say "#{section}:", :yellow
|
|
522
|
-
emit_rows(items.sort_by { |full, _| full }, width)
|
|
642
|
+
emit_rows(items.sort_by { |full, _| [full.count(':'), full] }, width)
|
|
523
643
|
end
|
|
524
644
|
end
|
|
525
645
|
|
|
526
646
|
def emit_rows(rows, width)
|
|
527
647
|
rows.each do |full, c|
|
|
528
|
-
|
|
529
|
-
Shell.say " #{program_name} #{
|
|
648
|
+
brief = c.alts.empty? ? c.brief : "#{c.brief} (alt: #{c.alts.join(', ')})"
|
|
649
|
+
Shell.say " #{program_name} #{full.ljust(width)} # #{brief}"
|
|
530
650
|
end
|
|
531
651
|
end
|
|
532
652
|
|
|
@@ -534,17 +654,18 @@ class Hammer
|
|
|
534
654
|
# for 'db:users:list' viewed from 'db'; :root if the command sits at
|
|
535
655
|
# the view's top level. Only the first segment under the view groups,
|
|
536
656
|
# so deeper paths fold into their top-level section.
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
657
|
+
#
|
|
658
|
+
# Exception: a bare command that shares its name with a sibling
|
|
659
|
+
# namespace (e.g. `mount` alongside a `mount:` namespace) groups
|
|
660
|
+
# under that namespace's section, not :root.
|
|
661
|
+
def section_for(full, prefix, klass = nil)
|
|
662
|
+
segs = full.split(':')
|
|
663
|
+
segs = segs[prefix.split(':').size..] || [] if prefix && !prefix.empty?
|
|
664
|
+
if segs.size == 1 && klass && klass.namespaces.key?(segs.first)
|
|
665
|
+
return segs.first
|
|
541
666
|
end
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
# "db:migrate" or "db:migrate (alt: m)"
|
|
546
|
-
def label_for(full, cmd)
|
|
547
|
-
cmd.alts.empty? ? full : "#{full} (alt: #{cmd.alts.join(', ')})"
|
|
667
|
+
parent = segs[0..-2]
|
|
668
|
+
parent.empty? ? :root : parent.first
|
|
548
669
|
end
|
|
549
670
|
|
|
550
671
|
# " URL [ENV] [OPTIONS]" - shows the positional-fill names for
|
|
@@ -607,11 +728,11 @@ class Hammer
|
|
|
607
728
|
def self.run(argv = ARGV, &block)
|
|
608
729
|
klass = Class.new(Hammer)
|
|
609
730
|
if block
|
|
610
|
-
Builder.new(klass).
|
|
731
|
+
Builder.new(klass).evaluate(&block)
|
|
611
732
|
else
|
|
612
733
|
hf = File.join(Dir.pwd, 'Hammerfile')
|
|
613
734
|
if File.file?(hf)
|
|
614
|
-
Builder.new(klass).
|
|
735
|
+
Builder.new(klass).evaluate(File.read(hf), hf)
|
|
615
736
|
else
|
|
616
737
|
klass.loader.load(Dir.pwd, [], auto: true)
|
|
617
738
|
end
|
|
@@ -676,6 +797,9 @@ class Hammer
|
|
|
676
797
|
end
|
|
677
798
|
|
|
678
799
|
klass = Class.new(Hammer)
|
|
800
|
+
# Mark this class as the `hammer` binary's root so help output can
|
|
801
|
+
# surface binary-only globals like `--ai`.
|
|
802
|
+
klass.instance_variable_set(:@hammer_binary, true)
|
|
679
803
|
# Resolve before chdir so paths like `bin/foo` stay relative to the
|
|
680
804
|
# cwd the user actually invoked from. `program_name` memoizes.
|
|
681
805
|
klass.program_name
|
|
@@ -683,7 +807,11 @@ class Hammer
|
|
|
683
807
|
# chdir into the Hammerfile's directory for the entire run so commands
|
|
684
808
|
# operate on the project root (Rake-style).
|
|
685
809
|
Dir.chdir(File.dirname(path))
|
|
686
|
-
Builder.new(klass).
|
|
810
|
+
Builder.new(klass).evaluate(File.read(path), path)
|
|
811
|
+
# Auto-load `.env` / `.env.local` after eval so a top-level
|
|
812
|
+
# `dotenv false` in the Hammerfile can suppress it. Trade-off: vars
|
|
813
|
+
# are NOT visible during Hammerfile evaluation, only inside handlers.
|
|
814
|
+
Hammer::Dotenv.load(Dir.pwd) if klass.dotenv_enabled?
|
|
687
815
|
klass.start(argv)
|
|
688
816
|
end
|
|
689
817
|
|
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.2.
|
|
4
|
+
version: 0.2.7
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dino Reic
|
|
@@ -38,6 +38,7 @@ files:
|
|
|
38
38
|
- "./lib/hammer/builder.rb"
|
|
39
39
|
- "./lib/hammer/command.rb"
|
|
40
40
|
- "./lib/hammer/command_builder.rb"
|
|
41
|
+
- "./lib/hammer/dotenv.rb"
|
|
41
42
|
- "./lib/hammer/loader.rb"
|
|
42
43
|
- "./lib/hammer/option.rb"
|
|
43
44
|
- "./lib/hammer/parser.rb"
|
|
@@ -62,7 +63,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
62
63
|
- !ruby/object:Gem::Version
|
|
63
64
|
version: '0'
|
|
64
65
|
requirements: []
|
|
65
|
-
rubygems_version: 4.0.
|
|
66
|
+
rubygems_version: 4.0.11
|
|
66
67
|
specification_version: 4
|
|
67
68
|
summary: Thor-inspired tiny CLI builder
|
|
68
69
|
test_files: []
|