lux-hammer 0.3.1 → 0.3.3

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: f0f8f5abc50111342c80fbfc411408159e625d00e52a31dc8f8956ac658c21f0
4
- data.tar.gz: d05eb3f51a19efa9d1a9328229a4dbb5b680eb91c1b36c9cb8f5e973cfc6f17d
3
+ metadata.gz: 8b7ace2215fd4ef5e5edf6ee82a418c4ff56770f2d15bdc6f6127f5bd155bba8
4
+ data.tar.gz: 2a6734f905d7d30f1cb1536a6a6004ceb60fde702a7a87ec2ce7f7a7efb75992
5
5
  SHA512:
6
- metadata.gz: 5bf720911c5338c70a41f29af05ee2fad030ec54faad56727d9843bdae46355dbf22ef17e722c9196edf2aba7cdc9458f50dade6ca518f4d2709eeb2888ed43c
7
- data.tar.gz: 005cc75962461f3e7c6e11ae04e31b99cec50b5cc1401b27ba968a28a64bf648599097d133fe8bd306896e99c5b76e020546b6cae4b867af85b015d19da5a2e3
6
+ metadata.gz: e211c85aa36eda42822fa8c2181bea5e4128a10de60e7d22574eb3f6f721c2dc556ceb432cd5bcb2c0e2644a30a0ed8f2d4ae5ff3503cdaf618f70a3917f5329
7
+ data.tar.gz: 54fdb3f2dbe42ab5cc4312c4a969d3a43fe0fb420336c32702efcca2a2790ad7df8cf90d2eea5949ee6cb2ce7a77e33217b784144ef7baceb9abf1a2ead125ce
data/.version CHANGED
@@ -1 +1 @@
1
- 0.3.1
1
+ 0.3.3
data/AGENTS.md CHANGED
@@ -44,6 +44,9 @@ lib/hammer/command.rb # One registered command (name, opts, alts, handler)
44
44
  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
+ lib/hammer/recipe.rb # Recipe discovery (gem + user dir, desc, stub)
48
+ lib/hammer/builtins.rb # Lazy-loaded `self:` namespace (recipe, ai, update)
49
+ recipes/ # Bundled recipes (each `<name>.rb` -> a bin via stub)
47
50
  test/dsl_test.rb # DSL surface, dispatch, help formatting
48
51
  test/load_test.rb # `load` / `*_hammer.rb` fragment loader
49
52
  test/parser_test.rb # ARGV parsing edge cases
@@ -80,10 +83,25 @@ At class scope (for `def`-style commands):
80
83
  * Methods with arity 0 are called without opts; methods that take an arg
81
84
  receive the opts hash
82
85
 
86
+ At Hammerfile (block-DSL) top-level scope only:
87
+
88
+ * `desc 'CLI description'` - top-level summary shown under the `Usage:`
89
+ line in `hammer --help`. Multi-line allowed (via heredoc). This is
90
+ distinct from the class-DSL `desc` (which sets per-task pending
91
+ state for the next `def`).
92
+ * `helpers do ... end` - shorthand for `class_eval` on the underlying
93
+ Hammer subclass. Use it to define private instance methods that task
94
+ procs can call as bare names (procs are `instance_exec`'d on the
95
+ subclass instance, so anything defined here is in scope). Helpers
96
+ can also call `say` / `choose` / `error` / `yes?` directly since
97
+ Hammer mixes in `Shell`.
98
+
83
99
  At class or `Hammerfile` scope:
84
100
 
85
101
  * `task :name do ... end`
86
- * `namespace :name do ... end`
102
+ * `namespace :name do ... end` - `:self` is reserved for the `hammer`
103
+ binary's built-in namespace (see `lib/hammer/builtins.rb`). Defining
104
+ it from user code raises.
87
105
  * `dotenv false` - opt out of auto `.env` / `.env.local` loading.
88
106
  Only meaningful from the `hammer` binary (`Hammer.cli`); the load
89
107
  happens after Hammerfile evaluation, before dispatch. Shell-set vars
@@ -125,9 +143,15 @@ Runtime cross-invocation:
125
143
  (`bin/hammer`).
126
144
  * `Hammer.cli(argv = ARGV)` - internal entry for `bin/hammer` only:
127
145
  walks up from `Dir.pwd` for a `Hammerfile`, chdirs into its dir,
128
- errors if none found anywhere up the tree. Not part of the
129
- user-facing surface - don't recommend it in examples; `Hammer.run`
130
- is what library users should reach for.
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.
151
+ * `Hammer.recipe(name, argv = ARGV)` - entry for recipe stubs in PATH.
152
+ Loads `<gem>/recipes/<name>.rb` (or its user-dir override), runs as
153
+ a standalone CLI with `program_name = name`. No Hammerfile lookup,
154
+ no chdir, no dotenv auto-load. See `lib/hammer/recipe.rb`.
131
155
  * `class MyCli < Hammer; ... end; MyCli.start(ARGV)` - classic class
132
156
  DSL. `.start` is the dispatch primitive everything else funnels into.
133
157
 
data/README.md CHANGED
@@ -63,11 +63,26 @@ gem install lux-hammer
63
63
 
64
64
  This installs the `hammer` binary and exposes `require 'lux-hammer'`.
65
65
 
66
+ ### Install from GitHub (latest main)
67
+
68
+ ```sh
69
+ curl -fsSL https://raw.githubusercontent.com/dux/hammer/main/install.sh | bash
70
+ ```
71
+
72
+ Clones into `~/.local/share/lux-hammer` (override with `LUX_HAMMER_DIR=`),
73
+ builds the gem, and installs it. Re-run any time, or use:
74
+
75
+ ```sh
76
+ hammer --update # git pull main + rebuild + reinstall
77
+ ```
78
+
66
79
  ## Quick start
67
80
 
68
81
  Create a `Hammerfile` in your project root:
69
82
 
70
83
  ```ruby
84
+ desc 'My project tools - build, deploy, test'
85
+
71
86
  task :hello do
72
87
  desc 'say hi'
73
88
  proc do |opts|
@@ -86,6 +101,8 @@ hello dino
86
101
  $ hammer
87
102
  Usage: hammer COMMAND [ARGS]
88
103
 
104
+ My project tools - build, deploy, test
105
+
89
106
  Commands:
90
107
  hammer hello # say hi
91
108
  ```
@@ -93,6 +110,10 @@ Commands:
93
110
  That's it. `hammer` walks up from your current directory looking for a
94
111
  `Hammerfile`, evaluates it, and dispatches.
95
112
 
113
+ The top-level `desc 'text'` is optional - one line (or multi-line via
114
+ a heredoc) describing what the CLI is for. It renders right under the
115
+ `Usage:` line in `hammer --help`.
116
+
96
117
  ## Why hammer (the short pitch)
97
118
 
98
119
  A handful of papercuts from Rake and Thor that hammer just doesn't have.
@@ -462,6 +483,10 @@ hammer : # trailing colon at root: full help for every command
462
483
  Namespaces nest to any depth. There is no per-level dispatch - the root
463
484
  parses the whole colon path and walks the namespace tree.
464
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
+
465
490
  ## Pre-hooks (`before`)
466
491
 
467
492
  A `before do ... end` block at the root scope or inside a `namespace`
@@ -971,6 +996,78 @@ Options:
971
996
  --admin
972
997
  ```
973
998
 
999
+ ## Recipes (shareable mini-CLIs shipped with hammer)
1000
+
1001
+ A **recipe** is a standalone Hammerfile-style script bundled inside the
1002
+ `lux-hammer` gem under `recipes/`. Each recipe is exposed as its own
1003
+ top-level binary in your `PATH` - so `srt` becomes a real command, not
1004
+ `hammer srt:shift`.
1005
+
1006
+ Listing what's available:
1007
+
1008
+ ```sh
1009
+ $ hammer self:recipe
1010
+ gem:
1011
+ srt # Subtitle (.srt) toolkit - shift timestamps, show stats
1012
+ [install: hammer self:recipe install srt]
1013
+ ```
1014
+
1015
+ Installing one (you control the path; nothing is written for you):
1016
+
1017
+ ```sh
1018
+ $ hammer self:recipe install srt > ~/bin/srt && chmod +x ~/bin/srt
1019
+ $ srt --help
1020
+ Usage: srt COMMAND [ARGS]
1021
+
1022
+ Tiny .srt subtitle helper.
1023
+
1024
+ Commands:
1025
+ srt info # Print cue count and total duration
1026
+ srt shift # Shift every timestamp by N seconds
1027
+ ```
1028
+
1029
+ The stub is a 3-line Ruby wrapper that re-enters lux-hammer at runtime,
1030
+ so the recipe always runs the version currently in the gem.
1031
+
1032
+ ### Authoring your own
1033
+
1034
+ 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`,
1037
+ `load`. Example `~/.config/hammer/recipes/json.rb`:
1038
+
1039
+ ```ruby
1040
+ # desc: tiny JSON pretty-printer / minifier
1041
+
1042
+ task :pretty do
1043
+ desc 'Pretty-print JSON from stdin or a file'
1044
+ proc do |opts|
1045
+ require 'json'
1046
+ src = opts[:args].first ? File.read(opts[:args].first) : $stdin.read
1047
+ puts JSON.pretty_generate(JSON.parse(src))
1048
+ end
1049
+ end
1050
+ ```
1051
+
1052
+ Install it the same way:
1053
+
1054
+ ```sh
1055
+ $ hammer self:recipe install json > ~/bin/json && chmod +x ~/bin/json
1056
+ ```
1057
+
1058
+ User-dir recipes override gem recipes with the same name, so you can
1059
+ fork without forking.
1060
+
1061
+ ### Other `self:recipe` actions
1062
+
1063
+ ```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)
1069
+ ```
1070
+
974
1071
  ## Programmatic use
975
1072
 
976
1073
  Outside a Hammerfile, you can build a `Hammer` subclass and run it
@@ -7,6 +7,30 @@ class Hammer
7
7
  @klass = klass
8
8
  end
9
9
 
10
+ # Top-level CLI description, shown under the Usage line in
11
+ # `hammer --help`. Multi-line strings render with each line indented.
12
+ def desc(text)
13
+ @klass.app_desc(text)
14
+ end
15
+
16
+ # Open the underlying Hammer subclass to add private instance methods
17
+ # callable from inside task procs. Lets recipe / Hammerfile authors
18
+ # share helpers without a `Foo.method` prefix or a separate module:
19
+ #
20
+ # helpers do
21
+ # def run(cmd) ; say cmd, :gray ; system cmd ; end
22
+ # end
23
+ #
24
+ # task :ship do
25
+ # proc { run 'git push' }
26
+ # end
27
+ #
28
+ # Procs run via `instance.instance_exec(opts, &handler)` on a fresh
29
+ # subclass instance, so anything `class_eval`'d here is in scope.
30
+ def helpers(&block)
31
+ @klass.class_eval(&block)
32
+ end
33
+
10
34
  def task(name, &block)
11
35
  @klass.task(name, &block)
12
36
  end
@@ -59,6 +83,26 @@ class Hammer
59
83
  target.send(m, *args, &block)
60
84
  end
61
85
  end
86
+
87
+ # Top-level `desc 'text'` from inside a Hammerfile / fragment - sets
88
+ # the CLI's overall description on the current target. Separate from
89
+ # the class-level `desc` (which is per-task pending state).
90
+ def desc(text)
91
+ target = Thread.current[:hammer_target]
92
+ raise Hammer::Error, '`desc` called outside a Hammer context ' \
93
+ '(Hammerfile / Hammer.run block / *_hammer.rb)' unless target
94
+ target.app_desc(text)
95
+ end
96
+
97
+ # Same as Builder#helpers - top-level `helpers do ... end` in a
98
+ # fragment or Hammerfile adds private instance methods to the
99
+ # current target's class.
100
+ def helpers(&block)
101
+ target = Thread.current[:hammer_target]
102
+ raise Hammer::Error, '`helpers` called outside a Hammer context ' \
103
+ '(Hammerfile / Hammer.run block / *_hammer.rb)' unless target
104
+ target.class_eval(&block)
105
+ end
62
106
  end
63
107
  end
64
108
 
@@ -0,0 +1,181 @@
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.
7
+ module Builtins
8
+ module_function
9
+
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 }
18
+ end
19
+
20
+ task :update do
21
+ desc 'Update lux-hammer from github main (requires install.sh checkout)'
22
+ proc { Hammer.self_update }
23
+ end
24
+
25
+ task :recipe do
26
+ 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
35
+ 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'
42
+ proc do |opts|
43
+ action, name, *rest = opts[:args]
44
+ Hammer::Builtins::Recipes.dispatch(action, name, rest)
45
+ end
46
+ end
47
+ end
48
+ ensure
49
+ Thread.current[:hammer_builtins_loading] = nil
50
+ end
51
+
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
71
+ end
72
+
73
+ def require_name!(name, action)
74
+ return name if name
75
+ Shell.print_error "missing recipe name (usage: self:recipe #{action} NAME)"
76
+ exit 1
77
+ end
78
+
79
+ # Group by source dir; each row shows desc and installed-or-not.
80
+ def list
81
+ groups = Hammer::Recipe.grouped
82
+ if groups.empty?
83
+ Shell.say 'no recipes found', :gray
84
+ return
85
+ end
86
+
87
+ rows = Hammer::Recipe.all.map do |name, path|
88
+ [name, Hammer::Recipe.desc(path), Hammer::Recipe.installed_path(name)]
89
+ end
90
+ width = rows.map { |n, _, _| n.length }.max
91
+
92
+ groups.each_with_index do |(source, items), i|
93
+ Shell.say '' if i > 0
94
+ Shell.say "#{source}:", :yellow
95
+ items.each_key do |name|
96
+ _n, desc, installed = rows.find { |r| r.first == name }
97
+ status = installed ? "(installed: #{installed})" : "[install: hammer self:recipe install #{name}]"
98
+ line = " #{name.ljust(width)} # #{desc}"
99
+ Shell.say line
100
+ Shell.say " #{' ' * width} #{status}", :gray
101
+ end
102
+ end
103
+ end
104
+
105
+ # With no NAME: arrow-key picker. With NAME only: print the stub
106
+ # to stdout (user pipes it themselves). With NAME + TARGET: write
107
+ # the stub to TARGET and chmod +x in one shot.
108
+ def install(name, target = nil)
109
+ if name.nil?
110
+ names = Hammer::Recipe.all.keys.sort
111
+ if names.empty?
112
+ Shell.print_error 'no recipes available'
113
+ exit 1
114
+ end
115
+ idx = Shell.choose('pick a recipe to install', names)
116
+ exit 1 if idx.nil?
117
+ name = names[idx]
118
+ end
119
+
120
+ unless Hammer::Recipe.path(name)
121
+ Shell.print_error "unknown recipe: #{name}"
122
+ exit 1
123
+ end
124
+
125
+ stub = Hammer::Recipe.stub(name)
126
+ if target
127
+ path = File.expand_path(target)
128
+ File.write(path, stub)
129
+ File.chmod(0o755, path)
130
+ Shell.say "installed #{name} -> #{path}", :green
131
+ else
132
+ puts stub
133
+ end
134
+ end
135
+
136
+ def show(name)
137
+ path = Hammer::Recipe.path(name) or fail_unknown(name)
138
+ puts File.read(path)
139
+ end
140
+
141
+ def path(name)
142
+ path = Hammer::Recipe.path(name) or fail_unknown(name)
143
+ puts path
144
+ end
145
+
146
+ # For a gem recipe, offer to copy to user dir first so edits
147
+ # survive `hammer self:update`. Then exec $EDITOR on the file.
148
+ def edit(name)
149
+ path = Hammer::Recipe.path(name) or fail_unknown(name)
150
+ editor = ENV['EDITOR'] || ENV['VISUAL']
151
+ unless editor
152
+ Shell.print_error '$EDITOR not set'
153
+ exit 1
154
+ end
155
+
156
+ if path.start_with?(Hammer::Recipe::GEM_DIR)
157
+ user_dir = ENV['HAMMER_RECIPES_DIR'] || File.expand_path('~/.config/hammer/recipes')
158
+ target = File.join(user_dir, "#{name}.rb")
159
+ unless File.exist?(target)
160
+ if Shell.yes?("copy gem recipe to #{target} before editing? (recommended)")
161
+ require 'fileutils'
162
+ FileUtils.mkdir_p(user_dir)
163
+ FileUtils.cp(path, target)
164
+ Shell.say "copied to #{target}", :green
165
+ path = target
166
+ end
167
+ else
168
+ path = target
169
+ end
170
+ end
171
+
172
+ system(editor, path)
173
+ end
174
+
175
+ def fail_unknown(name)
176
+ Shell.print_error "unknown recipe: #{name}"
177
+ exit 1
178
+ end
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,92 @@
1
+ class Hammer
2
+ # Discovery + helpers for "recipes" - standalone Hammerfile-style
3
+ # scripts shipped under `<gem>/recipes/` (and optionally the user's
4
+ # `~/.config/hammer/recipes/`) that are exposed as their own bin
5
+ # scripts in PATH via a tiny Ruby wrapper.
6
+ module Recipe
7
+ module_function
8
+
9
+ GEM_DIR ||= File.expand_path('../../recipes', __dir__)
10
+
11
+ # Recipe lookup directories, in priority order. User dir is only
12
+ # included when it exists. Honors HAMMER_RECIPES_DIR for overrides
13
+ # (mostly for tests).
14
+ def dirs
15
+ out = [GEM_DIR]
16
+ user = ENV['HAMMER_RECIPES_DIR'] || File.expand_path('~/.config/hammer/recipes')
17
+ out.unshift(user) if File.directory?(user)
18
+ out
19
+ end
20
+
21
+ # { "name" => "/abs/path/name.rb" }. User-dir entries win over gem
22
+ # entries because they come first in `dirs`.
23
+ def all
24
+ out = {}
25
+ dirs.each do |dir|
26
+ next unless File.directory?(dir)
27
+ Dir.glob(File.join(dir, '*.rb')).sort.each do |path|
28
+ name = File.basename(path, '.rb')
29
+ out[name] ||= path
30
+ end
31
+ end
32
+ out
33
+ end
34
+
35
+ # Path to the recipe file for `name`, or nil if unknown.
36
+ def path(name)
37
+ all[name.to_s]
38
+ end
39
+
40
+ # First `# desc: ...` line at the top of the file, or empty string.
41
+ # Cheap - reads only the first ~20 lines, no eval.
42
+ def desc(path)
43
+ return '' unless path && File.file?(path)
44
+ File.foreach(path).first(20).each do |line|
45
+ if (m = line.match(/\A#\s*desc:\s*(.+)$/i))
46
+ return m[1].strip
47
+ end
48
+ end
49
+ ''
50
+ end
51
+
52
+ # Ruby wrapper text printed by `hammer self:recipe install`. User
53
+ # redirects it to a file in PATH and chmods +x. The leading comment
54
+ # documents the canonical install command. Name is passed as a
55
+ # string literal so hyphenated names (`git-helper`) work too.
56
+ def stub(name)
57
+ <<~RUBY
58
+ #!/usr/bin/env ruby
59
+ # install: hammer self:recipe install #{name} > ~/bin/#{name} && chmod +x $_
60
+ require 'lux-hammer'
61
+ Hammer.recipe('#{name}', ARGV)
62
+ RUBY
63
+ end
64
+
65
+ # If a stub for `name` is on PATH, return its absolute path. Detects
66
+ # by reading the file and looking for the literal `Hammer.recipe('<name>'`
67
+ # token - cheap and near-zero false positive.
68
+ def installed_path(name)
69
+ token = "Hammer.recipe('#{name}'"
70
+ ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).each do |dir|
71
+ candidate = File.join(dir, name.to_s)
72
+ next unless File.file?(candidate) && File.executable?(candidate)
73
+ return candidate if File.read(candidate, 512).include?(token)
74
+ rescue StandardError
75
+ next
76
+ end
77
+ nil
78
+ end
79
+
80
+ # Group recipes by their source directory for the listing view.
81
+ # Returns [[source_label, { name => path }], ...].
82
+ def grouped
83
+ user_dir = ENV['HAMMER_RECIPES_DIR'] || File.expand_path('~/.config/hammer/recipes')
84
+ groups = { 'gem' => {}, 'user' => {} }
85
+ all.each do |name, path|
86
+ bucket = path.start_with?(user_dir) ? 'user' : 'gem'
87
+ groups[bucket][name] = path
88
+ end
89
+ groups.reject { |_, v| v.empty? }.to_a
90
+ end
91
+ end
92
+ end