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 +4 -4
- data/.version +1 -1
- data/AGENTS.md +28 -4
- data/README.md +97 -0
- data/lib/hammer/builder.rb +44 -0
- data/lib/hammer/builtins.rb +181 -0
- data/lib/hammer/recipe.rb +92 -0
- data/lib/lux-hammer.rb +157 -9
- data/recipes/git-helper.rb +624 -0
- data/recipes/srt.rb +270 -0
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8b7ace2215fd4ef5e5edf6ee82a418c4ff56770f2d15bdc6f6127f5bd155bba8
|
|
4
|
+
data.tar.gz: 2a6734f905d7d30f1cb1536a6a6004ceb60fde702a7a87ec2ce7f7a7efb75992
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e211c85aa36eda42822fa8c2181bea5e4128a10de60e7d22574eb3f6f721c2dc556ceb432cd5bcb2c0e2644a30a0ed8f2d4ae5ff3503cdaf618f70a3917f5329
|
|
7
|
+
data.tar.gz: 54fdb3f2dbe42ab5cc4312c4a969d3a43fe0fb420336c32702efcca2a2790ad7df8cf90d2eea5949ee6cb2ce7a77e33217b784144ef7baceb9abf1a2ead125ce
|
data/.version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.3.
|
|
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.
|
|
129
|
-
|
|
130
|
-
|
|
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
|
data/lib/hammer/builder.rb
CHANGED
|
@@ -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
|