lux-hammer 0.3.8 → 0.3.10

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: 64f8d2c7864047c17b42187e610ac14e93982a5ff1bd8dc6187a35670f67e432
4
- data.tar.gz: 07b7014dcdfe70c48c293e7e18ce05475dcd565ee2c66310afa0c3a16d371f02
3
+ metadata.gz: 81af43fc44619384d2b62f58e7d3178a1b6edb22cbb1ef4e836b73e2ee469913
4
+ data.tar.gz: 47d012b5e54d1e8df3441de01eeaee3e3267f71c4ac8545b80f413d419bd3fc6
5
5
  SHA512:
6
- metadata.gz: f1f4515e1340b132865081e4ff4118789540fd2c7c880d2ec42330d174e6f41f88b5355f40727207fa0320811bdba6f134d70a81604760221e2189be6cd73b7e
7
- data.tar.gz: f43b40bd53e03f3c2a4ad04f5e8a886863c038b79f2cf18e39f96eb8e09bb3e8dfaa3c5a6610fc0acaee8f76c6a31860a0dc10f4a181c80fbc8ee64e5a45d32b
6
+ metadata.gz: d1dd7d495b96a6f7023601427dadcd492e593934c6407eb48dcb7c53fea26e33ea44ec40ef3a69856199fa75dd9d6278a8d32b14cbd4733b692dbbfe662f97ee
7
+ data.tar.gz: 2a9b223844fd645a79403c653a2bf25be28ba5654eef8562738b2f3768b0a72bddfd8b5ceb3dfe68ac7c583ecaaefc18fcee4d64f3f47964a93ff8f12b0fb586
data/.version CHANGED
@@ -1 +1 @@
1
- 0.3.8
1
+ 0.3.10
data/README.md CHANGED
@@ -946,6 +946,41 @@ Hammer.run ARGV do
946
946
  end
947
947
  ```
948
948
 
949
+ ### `#!/usr/bin/env hammer` (single-file scripts)
950
+
951
+ For a one-off CLI that lives as a single executable file, point the
952
+ shebang straight at `hammer` - no `require`, no boilerplate:
953
+
954
+ ```ruby
955
+ #!/usr/bin/env hammer
956
+ # desc: tiny greeter
957
+
958
+ task :hello do
959
+ desc 'say hi'
960
+ opt :loud, type: :boolean, alias: :l
961
+ proc do |opts|
962
+ msg = "hello #{opts[:args].first || 'world'}"
963
+ say.cyan(opts[:loud] ? msg.upcase : msg)
964
+ end
965
+ end
966
+ ```
967
+
968
+ ```sh
969
+ $ chmod +x greet
970
+ $ ./greet hello dino -l
971
+ HELLO DINO
972
+ $ ./greet --help
973
+ Usage: greet COMMAND [ARGS]
974
+ ...
975
+ ```
976
+
977
+ The file body is plain Hammerfile DSL (`task`, `namespace`, `before`,
978
+ `load`). `hammer` detects the shebang, evaluates the script as a
979
+ self-contained CLI, and uses the script's basename as the program name
980
+ in help - so `./greet --help` reads "Usage: greet ..." rather than
981
+ "Usage: hammer ...". The current working directory is left alone, so
982
+ relative paths inside the script resolve where the user ran it.
983
+
949
984
  ## Complete example (every feature)
950
985
 
951
986
  ```ruby
@@ -1192,21 +1227,19 @@ few small things that have been bugging me about both for years.
1192
1227
 
1193
1228
  | | Thor | hammer |
1194
1229
  |-|-|-|
1195
- | Lines of code | ~6,000 | ~400 |
1196
1230
  | Runtime deps | a few | zero |
1197
- | Root constants | `Thor`, `Thor::Group`, `Thor::Shell`, `Thor::Actions`, ... | just `Hammer` |
1198
1231
  | Command DSL | `desc 'usage', 'help'` + `method_option` + `def name(arg)` | `task :name do ... proc do \|opts\| end end` (or classic `desc` + `def`) |
1199
1232
  | Opts container | `Thor::CoreExt::HashWithIndifferentAccess` | plain `Hash` with symbol keys |
1200
1233
  | Positional args | method positional params + `method_option`, two parallel systems | declared-order opts fill from positional, single system |
1201
1234
  | Sub-namespaces | `register SubClass, 'name', '...'` (inheritance ceremony) | `namespace :name do ... end` (no classes needed) |
1235
+ | Command aliases | none (workarounds via `map`) | `alt :s, :srv` |
1236
+ | Pre-hooks | none built-in | `before { ... }` per scope, `needs :env` per command |
1237
+ | Chained dispatch | no | `hammer build + deploy + notify` |
1202
1238
  | Cross-invoke | `invoke 'name', [args], opts` | `hammer :name, **opts` (looks like a method call) |
1203
1239
  | Inline CLI | class only | class DSL **or** `Hammer.run do ... end` block DSL **or** a `Hammerfile` |
1204
1240
 
1205
1241
  **What hammer does better and why:**
1206
1242
 
1207
- * **One root constant.** Thor exposes `Thor`, `Thor::Group`, `Thor::Shell`,
1208
- `Thor::Actions` at the top level - Bundler had to vendor its own copy at
1209
- `Bundler::Thor` to avoid clashes. Hammer is just `Hammer`.
1210
1243
  * **The opts hash is just a Hash.** Symbol keys, always. No magic accessor
1211
1244
  object to remember, no string-vs-symbol confusion, no method_missing.
1212
1245
  * **Positional args fill opts in declaration order.** Thor either forces
@@ -1225,15 +1258,18 @@ few small things that have been bugging me about both for years.
1225
1258
  | | Rake | hammer |
1226
1259
  |-|-|-|
1227
1260
  | Primary use case | build/task automation with file deps | general CLIs |
1228
- | Task file | `Rakefile` | `Hammerfile` |
1261
+ | Task file | `Rakefile` | `Hammerfile` (walks up parent dirs) |
1229
1262
  | Namespacing | colon paths (`db:migrate`) | colon paths (`db:migrate`) - parity |
1230
1263
  | Per-task options | `task[a,b,c]` positional only | typed `opt`s with flags, aliases, defaults, required |
1231
1264
  | Help | `rake -T` (plain list) | bare `hammer` lists everything grouped by namespace; `hammer X -h` for per-command help with examples and defaults |
1232
1265
  | Cross-invoke | `Rake::Task['db:migrate'].invoke` | `hammer 'db:migrate'` |
1233
- | Prerequisites | `task :build => [:clean, :compile]` (declarative DAG) | explicit - call `hammer :clean; hammer :compile` in the proc |
1266
+ | Prerequisites | `task :build => [:clean, :compile]` (declarative DAG) | `needs :clean, :compile` (declarative, deduped across `+` chains) |
1267
+ | Pre-hooks | none built-in | `before { ... }` scoped to root or namespace |
1268
+ | Chained dispatch | no (multi-task argv runs sequentially, no per-task flags) | `hammer build prod -v + db:migrate --pretend + deploy` |
1234
1269
  | File tasks | yes (mtime-based) | no |
1235
1270
  | Aliases | none (workarounds via re-defined tasks) | `alt :short_name` |
1236
1271
  | Split across files | `import 'other.rake'` | `load auto: true` (or explicit paths/globs) |
1272
+ | Shareable scripts | no | recipes (one-file CLIs installable as their own binary) |
1237
1273
 
1238
1274
  **What hammer does better and why:**
1239
1275
 
@@ -39,5 +39,34 @@ class Hammer
39
39
  name = name.to_s
40
40
  name == @name || @alts.include?(name)
41
41
  end
42
+
43
+ # Auto-assign a single-letter short alias (first letter of the opt
44
+ # name) to any opt that does not already declare one. Explicit
45
+ # aliases and `-h` are reserved first, so they always win. On
46
+ # collision the opt simply gets no short form - long flag still
47
+ # works. Idempotent.
48
+ def finalize!
49
+ return if @finalized
50
+ @finalized = true
51
+
52
+ claimed = ['-h']
53
+ @options.each do |o|
54
+ o.aliases.each { |a| claimed << a if short_flag?(a) }
55
+ end
56
+
57
+ @options.each do |o|
58
+ next if o.aliases.any? { |a| short_flag?(a) }
59
+ short = "-#{o.name.to_s[0]}"
60
+ next if claimed.include?(short)
61
+ o.aliases << short
62
+ claimed << short
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def short_flag?(switch)
69
+ switch.length == 2 && switch.start_with?('-') && switch[1] != '-'
70
+ end
42
71
  end
43
72
  end
data/lib/hammer/parser.rb CHANGED
@@ -63,7 +63,11 @@ class Hammer
63
63
  @options.each do |opt|
64
64
  break if positional.empty?
65
65
  next if opt.boolean? || values.key?(opt.name)
66
- values[opt.name] = opt.cast(positional.shift)
66
+ if opt.type == :array
67
+ values[opt.name] = opt.cast(positional.shift(positional.size))
68
+ else
69
+ values[opt.name] = opt.cast(positional.shift)
70
+ end
67
71
  end
68
72
 
69
73
  @options.each do |opt|
data/lib/hammer/shell.rb CHANGED
@@ -31,8 +31,8 @@ class Hammer
31
31
 
32
32
  # `say` with no args returns a proxy so you can write `say.cyan 'hi'`.
33
33
  # `say('')` still prints a blank line; `say('x', :cyan)` is unchanged.
34
- def say(text = nil, color = nil)
35
- return SayProxy.new if text.nil?
34
+ def say(text = :_say_no_arg, color = nil)
35
+ return SayProxy.new if text == :_say_no_arg
36
36
  puts paint(text, color)
37
37
  end
38
38
 
@@ -116,9 +116,9 @@ class Hammer
116
116
  $stdout.print "\e[#{items.size}A\r\e[J"
117
117
  $stdout.print "#{paint("> #{items[selected]}", :green)}\r\n"
118
118
  return selected
119
- when "\x03", 'q' # Ctrl-C, q
119
+ when "\x03" # Ctrl-C
120
120
  $stdout.print "\e[#{items.size}A\r\e[J"
121
- return nil
121
+ raise Interrupt
122
122
  when "\e"
123
123
  # ESC may stand alone or start an arrow sequence \e[A / \e[B.
124
124
  if IO.select([io], nil, nil, 0.01) && io.getch == '['
data/lib/lux-hammer.rb CHANGED
@@ -96,6 +96,7 @@ class Hammer
96
96
  m = method_name
97
97
  arity = instance_method(method_name).arity
98
98
  cmd.handler = arity.zero? ? proc { send(m) } : proc { |opts| send(m, opts) }
99
+ cmd.finalize!
99
100
  commands[cmd.name] = cmd
100
101
 
101
102
  @pending_desc = nil
@@ -168,6 +169,7 @@ class Hammer
168
169
  warn_redefinition('task', cmd.name, prev.location, cmd.location)
169
170
  end
170
171
 
172
+ cmd.finalize!
171
173
  commands[cmd.name] = cmd
172
174
 
173
175
  # `task` ignores pending class-level state, but clear it so a
@@ -414,6 +416,13 @@ class Hammer
414
416
  run_command(cmd, argv, full: name, quiet: true)
415
417
  end
416
418
 
419
+ # True when -h or --help appears in argv before a `--` stop-marker.
420
+ def help_requested?(argv)
421
+ stop = argv.index('--')
422
+ scan = stop ? argv[0...stop] : argv
423
+ scan.include?('-h') || scan.include?('--help')
424
+ end
425
+
417
426
  public
418
427
 
419
428
  # Find a command by canonical name or alt within this class. Falls
@@ -605,30 +614,26 @@ class Hammer
605
614
  end
606
615
  end
607
616
 
608
- def help_requested?(argv)
609
- stop = argv.index('--')
610
- scan = stop ? argv[0...stop] : argv
611
- scan.include?('-h') || scan.include?('--help')
612
- end
617
+ public
613
618
 
614
619
  # `extended: true` is the verbose `help` / `-h` / `--help` form -
615
620
  # appends global flags, the GitHub footer, and (for the hammer binary)
616
621
  # a Hammerfile example. Bare invocation passes `extended: false` so
617
622
  # the no-args output stays a clean command listing.
618
- def print_help(target = nil, full: false, extended: false)
623
+ def print_help(target = nil, expanded: false, extended: false)
619
624
  if target
620
625
  # `help ns:` is equivalent to `ns:` - namespace listing.
621
626
  if target.end_with?(':') && target != ':'
622
627
  bare = target.chomp(':')
623
628
  ns, canonical = resolve_namespace(bare)
624
- return print_namespace_help(canonical, ns, extended: extended) if ns
629
+ return print_namespace_help(canonical, ns) if ns
625
630
  Shell.print_error("unknown: #{target}")
626
631
  return
627
632
  end
628
633
  cmd, _, canonical = resolve(target)
629
634
  return print_command_help(cmd, canonical) if cmd
630
635
  ns, canonical = resolve_namespace(target)
631
- return print_namespace_help(canonical, ns, full: full, extended: extended) if ns
636
+ return print_namespace_help(canonical, ns, expanded: expanded) if ns
632
637
  Shell.print_error("unknown: #{target}")
633
638
  return
634
639
  end
@@ -639,7 +644,7 @@ class Hammer
639
644
  Shell.say ''
640
645
  @app_desc.each_line { |l| Shell.say " #{l.chomp}" }
641
646
  end
642
- if full
647
+ if expanded
643
648
  each_command { |path, c| print_full_block(path, c) unless c.desc.empty? }
644
649
  else
645
650
  Shell.say ''
@@ -670,10 +675,9 @@ class Hammer
670
675
 
671
676
  # `extended:` is accepted for parity with `print_help` but intentionally
672
677
  # not used here - the global-flags / Hammerfile-example / footer block
673
- # is root-help-only. A namespace listing is just the commands under
674
- # that prefix; tool-meta noise (Recipes: section) is reserved for
675
- # `hammer --help` at the top level.
676
- def print_namespace_help(prefix, ns, full: false, extended: false)
678
+ # is root-help-only. `expanded:` is also accepted for parity; a namespace
679
+ # listing is always the compact command list.
680
+ def print_namespace_help(prefix, ns, expanded: false, extended: false)
677
681
  Shell.say "Usage: #{program_name} #{prefix}:COMMAND [ARGS]", :cyan
678
682
  rows = []
679
683
  sibling = find_namespace_sibling(prefix)
@@ -989,6 +993,17 @@ class Hammer
989
993
  argv = argv.dup
990
994
  force_system = !!argv.delete('--system')
991
995
 
996
+ # Shebang invocation: `hammer /path/to/script ...args` (kernel passes
997
+ # the script path as argv[0] for `#!/usr/bin/env hammer` files).
998
+ # Treat the script as a self-contained CLI: no Hammerfile lookup, no
999
+ # chdir (commands run in the caller's cwd), no `hammer`-binary
1000
+ # built-ins/banners. Detection requires a `#!`+`hammer` first line so
1001
+ # task names that happen to be paths don't get hijacked.
1002
+ if (script = shebang_script(argv.first))
1003
+ argv.shift
1004
+ return run_shebang(script, argv)
1005
+ end
1006
+
992
1007
  path = force_system ? nil : find_hammerfile(Dir.pwd)
993
1008
  unless path
994
1009
  # No Hammerfile (or --system) - all built-ins are reachable. Bare
@@ -1058,6 +1073,33 @@ class Hammer
1058
1073
  klass.start(argv)
1059
1074
  end
1060
1075
 
1076
+ # Returns the script path if `arg` looks like a shebang script that
1077
+ # delegates to hammer (first line starts with `#!` and mentions
1078
+ # `hammer`). Returns nil otherwise. Used by `cli` to detect
1079
+ # `#!/usr/bin/env hammer` invocations where the kernel passes the
1080
+ # script path as argv[0].
1081
+ def self.shebang_script(arg)
1082
+ return nil unless arg
1083
+ return nil if arg.start_with?('-')
1084
+ return nil unless File.file?(arg) && File.readable?(arg)
1085
+ head = File.open(arg, &:gets).to_s
1086
+ return nil unless head.start_with?('#!') && head.include?('hammer')
1087
+ arg
1088
+ end
1089
+
1090
+ # Evaluate a shebang script as a self-contained CLI. Mirrors `recipe`
1091
+ # semantics: no chdir, no `@hammer_binary` flag, no `register_core`
1092
+ # built-ins (so the script's `--help` shows only what it defines).
1093
+ # `program_name` is the script's basename so help reads "myscript foo"
1094
+ # rather than "hammer foo" - works even when invoked via a symlink in
1095
+ # PATH, since argv[0] is the path the user typed.
1096
+ def self.run_shebang(path, argv)
1097
+ klass = Class.new(Hammer)
1098
+ klass.instance_variable_set(:@program_name, File.basename(path))
1099
+ Builder.new(klass).evaluate(File.read(path), path)
1100
+ klass.start(argv)
1101
+ end
1102
+
1061
1103
  # True if argv goes through a built-in dispatch path (`:default` or
1062
1104
  # `:help`) - meaning bare `hammer`, leading-flag invocations like
1063
1105
  # `hammer -h`, or explicit help requests. These don't need a project
@@ -1,4 +1,6 @@
1
+ #!/usr/bin/env hammer
1
2
  # desc: work with git (commit, push, pull, rebase, branch, redate, ...)
3
+ # executable: chmod +x this file and run directly, or symlink into PATH
2
4
 
3
5
  desc <<~TXT
4
6
  Git helper. Short aliases over common `git` workflows.
data/recipes/llm.rb CHANGED
@@ -1,4 +1,6 @@
1
+ #!/usr/bin/env hammer
1
2
  # desc: personal LLM utility CLI (memory store, prompt-token expander, ...)
3
+ # executable: chmod +x this file and run directly, or symlink into PATH
2
4
 
3
5
  desc <<~TXT
4
6
  llm - personal LLM utility CLI
@@ -54,6 +56,7 @@ namespace :memory do
54
56
  end
55
57
  [meta, body.to_s.sub(/\A\n+/, '')]
56
58
  end
59
+
57
60
  task :list do
58
61
  desc 'List stored memories with type and one-line description'
59
62
  example 'llm memory list'
data/recipes/srt.rb CHANGED
@@ -1,4 +1,6 @@
1
+ #!/usr/bin/env hammer
1
2
  # desc: Local SRT extraction via whisper.cpp
3
+ # executable: chmod +x this file and run directly, or symlink into PATH
2
4
 
3
5
  desc <<~TXT
4
6
  Local subtitle extraction with whisper.cpp.
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lux-hammer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.8
4
+ version: 0.3.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dino Reic
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-25 00:00:00.000000000 Z
11
+ date: 2026-05-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: minitest