lux-hammer 0.3.13 → 0.3.14

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: 411174afd97cefbe9e7e3309c25233f465b9458ee0dc710e67077725658876c0
4
- data.tar.gz: f4b64f7897fd8c67412fdbe418ad51b96eeac1ebb3e87ce9df5b97651a4a9ff6
3
+ metadata.gz: 7ea304583caa18c45db8f340aecf5e08803f23eebe4f9152eefec7ac77c64b18
4
+ data.tar.gz: 72eafeef47b8ae8c2ff41e226756199f085865899532c3d7669dd5025c4b4019
5
5
  SHA512:
6
- metadata.gz: ba1364fcff70fefa529a2e7586ee33f7826b43ee7cd3ebd5e454f0bce82131b427cf320d85d8d911edc3a11762196de7db9964c6877959548eb4c6753e5ac063
7
- data.tar.gz: 5b21c08ac766b1af02b1a0d49b7fb3129fffd94b0c13fd07c7a5995851c21447e6a437848f25ef30f364bfccb94f1bac51046630dbbd574aaa109a61a738e893
6
+ metadata.gz: 7cfa688c6d9d21acc72a6c8d614acefe2942746fb8b8b55f4f72fb08d9f1d37a6eccbd3326551348ea572889e3404790c4a59f64577d394f257e1ab826959c45
7
+ data.tar.gz: 535fb35b15ae31e9e861ad0ff32d9e216382b533cb877b12560185cd722724c0d01a0dde350facebf209178166d88c4109328e7882ba6a14ae0273eb111d2215
data/.version CHANGED
@@ -1 +1 @@
1
- 0.3.13
1
+ 0.3.14
data/AGENTS.md CHANGED
@@ -279,6 +279,9 @@ Task contracts:
279
279
  forward flags through). Bare invocation lists.
280
280
  * `h:init` - writes `Hammer::STARTER_HAMMERFILE` to `./Hammerfile`;
281
281
  refuses if one exists.
282
+ * `h:json` - `puts JSON` of `root.export_spec` (tasks grouped exactly
283
+ like the bare listing via `section_for`, root group keyed `__root`).
284
+ `--all` keeps the `h:` tree, `--compact` minifies.
282
285
 
283
286
  The `:default` task and the `help` / `-h` / `--help` requests are
284
287
  invoked via `run_command(cmd, argv, full: name, quiet: true)` - the
data/README.md CHANGED
@@ -562,6 +562,10 @@ Under the `h:` namespace:
562
562
  * `h:version` - print the lux-hammer version.
563
563
  * `h:recipes` - list / install / show / edit recipes.
564
564
  * `h:init` - write a starter Hammerfile in cwd (refuses if one exists).
565
+ * `h:json` - dump the CLI definition as JSON (tasks grouped like the
566
+ bare listing, with desc/options/examples/aliases/needs); `--all`
567
+ includes the `h:` tasks, `--compact` minifies. Output is plain stdout
568
+ (the run banner goes to stderr), so `hammer h:json | jq` just works.
565
569
 
566
570
  Each is guarded by `unless commands.key?` within the namespace, so you
567
571
  can override one by reopening `namespace :h` in your Hammerfile.
@@ -574,6 +578,16 @@ hammer --system h:recipes # list recipes, ignoring any local Hammer
574
578
  hammer --system h:recipes --install srt ~/bin/srt
575
579
  ```
576
580
 
581
+ `--gui` opens a native macOS window for the current project: a sidebar of
582
+ your tasks (grouped like the listing), a form per task built from its
583
+ options, and a Run button that streams the task's output. It reads the
584
+ project via `h:json` and runs each task as a `hammer <path> ...`
585
+ subprocess. macOS / arm64 only.
586
+
587
+ ```sh
588
+ hammer --gui # open the runner for the nearest Hammerfile
589
+ ```
590
+
577
591
  Customize bare `hammer` by replacing `:default`:
578
592
 
579
593
  ```ruby
@@ -0,0 +1,16 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>CFBundleName</key><string>Hammer</string>
6
+ <key>CFBundleDisplayName</key><string>Hammer</string>
7
+ <key>CFBundleIdentifier</key><string>com.lux-hammer.gui</string>
8
+ <key>CFBundleVersion</key><string>1</string>
9
+ <key>CFBundleShortVersionString</key><string>1.0</string>
10
+ <key>CFBundlePackageType</key><string>APPL</string>
11
+ <key>CFBundleExecutable</key><string>HammerGUI</string>
12
+ <key>LSMinimumSystemVersion</key><string>11.0</string>
13
+ <key>NSHighResolutionCapable</key><true/>
14
+ <key>NSPrincipalClass</key><string>NSApplication</string>
15
+ </dict>
16
+ </plist>
@@ -31,6 +31,7 @@ class Hammer
31
31
  register_version(h) unless h.commands.key?('version')
32
32
  register_recipes(h) unless h.commands.key?('recipes')
33
33
  register_init(h) unless h.commands.key?('init')
34
+ register_json(h) unless h.commands.key?('json')
34
35
  end
35
36
 
36
37
  def register_help(klass)
@@ -112,6 +113,29 @@ class Hammer
112
113
  end
113
114
  end
114
115
 
116
+ def register_json(klass)
117
+ klass.class_eval do
118
+ task :json do
119
+ desc <<~TXT
120
+ Dump the CLI definition as JSON: tasks grouped exactly like
121
+ the bare-`hammer` listing, each with desc, options, examples,
122
+ aliases, needs. Consumed by the macOS GUI and any tooling
123
+ that wants the full Hammerfile spec.
124
+ TXT
125
+ example 'h:json'
126
+ example 'h:json --all # include the reserved h: tasks'
127
+ example 'h:json --compact # minified, single line'
128
+ opt :all, type: :boolean, desc: 'include reserved built-in h: tasks'
129
+ opt :compact, type: :boolean, desc: 'minified JSON (default: pretty)'
130
+ proc do |opts|
131
+ require 'json'
132
+ spec = self.class.root.export_spec(include_builtins: opts[:all])
133
+ puts opts[:compact] ? JSON.generate(spec) : JSON.pretty_generate(spec)
134
+ end
135
+ end
136
+ end
137
+ end
138
+
115
139
  # `:recipes` rolls all recipe-management actions into one task. Bare
116
140
  # invocation lists; opts pick the action and positional args carry
117
141
  # the recipe name (and optional target path for --install). Run via
@@ -63,6 +63,26 @@ class Hammer
63
63
  end
64
64
  end
65
65
 
66
+ # Structured form for JSON export (`h:json`). `path` is the full
67
+ # colon path supplied by the tree walk - a Command doesn't know its
68
+ # own namespace prefix. `hidden` follows the help rule: a task with
69
+ # no `desc` still dispatches but is hidden from listings.
70
+ def to_h(path = name)
71
+ {
72
+ name: name,
73
+ path: path,
74
+ desc: desc,
75
+ brief: brief,
76
+ hidden: desc.empty?,
77
+ redefined: !prev_location.nil?,
78
+ location: location,
79
+ alts: alts,
80
+ needs: needs,
81
+ examples: examples,
82
+ options: options.map(&:to_h)
83
+ }
84
+ end
85
+
66
86
  private
67
87
 
68
88
  def short_flag?(switch)
data/lib/hammer/option.rb CHANGED
@@ -78,5 +78,24 @@ class Hammer
78
78
  return '' if default.nil?
79
79
  "(default: #{default.inspect})"
80
80
  end
81
+
82
+ # Structured form for JSON export (`h:json`). The GUI maps `type`
83
+ # to a form widget; `default`/`required`/`desc` decorate it. Mirrors
84
+ # the data behind `usage`. `negation` is only meaningful for booleans.
85
+ def to_h
86
+ h = {
87
+ name: name.to_s,
88
+ type: type.to_s,
89
+ default: default,
90
+ required: required,
91
+ desc: desc,
92
+ placeholder: placeholder,
93
+ switch: switch,
94
+ aliases: aliases,
95
+ usage: usage.strip
96
+ }
97
+ h[:negation] = negation if boolean?
98
+ h
99
+ end
81
100
  end
82
101
  end
data/lib/lux-hammer.rb CHANGED
@@ -583,6 +583,39 @@ class Hammer
583
583
  end
584
584
  end
585
585
 
586
+ # Machine-readable spec for `h:json` -> the macOS GUI (and, later,
587
+ # for lux itself to render the default listing). One hash:
588
+ # commands => { group => { full_path => task_meta } }
589
+ # Grouping/sort mirror the bare-`hammer` listing exactly: group by
590
+ # the first namespace segment (a bare task sharing a namespace's name
591
+ # joins that group via section_for), root tasks under "__root",
592
+ # "__root" first, remaining groups in first-encounter order, tasks
593
+ # within a group by [depth, name]. Hidden (no-`desc`) tasks are
594
+ # skipped and the reserved `h:` tree is pruned unless include_builtins.
595
+ def export_spec(include_builtins: false)
596
+ groups = {} # group => { full_path => meta }, in first-encounter order
597
+
598
+ each_command(include_builtins: include_builtins) do |path, c|
599
+ next if c.desc.empty?
600
+ section = section_for(path, nil, self)
601
+ key = section == :root ? '__root' : section.to_s
602
+ (groups[key] ||= {})[path] = c.to_h(path)
603
+ end
604
+
605
+ sort_tasks = ->(h) { h.sort_by { |p, _| [p.count(':'), p] }.to_h }
606
+ ordered = {}
607
+ ordered['__root'] = sort_tasks.call(groups.delete('__root')) if groups.key?('__root')
608
+ groups.each { |k, v| ordered[k] = sort_tasks.call(v) }
609
+
610
+ {
611
+ schema: 1,
612
+ hammer_version: VERSION,
613
+ program_name: program_name,
614
+ app_desc: app_desc,
615
+ commands: ordered
616
+ }
617
+ end
618
+
586
619
  def run_command(cmd, argv, full: nil, quiet: false)
587
620
  # -h / --help is reserved on every command. Anywhere before a `--`
588
621
  # stop-marker, it short-circuits to per-command help.
@@ -622,7 +655,9 @@ class Hammer
622
655
  end
623
656
  end
624
657
  parts.concat(positional)
625
- Shell.say "> #{parts.join(' ')}", :gray
658
+ # Diagnostic, not program output - to stderr so stdout stays clean
659
+ # for machine-readable tasks (`h:json`, `h:version`) and pipes.
660
+ warn Shell.paint("> #{parts.join(' ')}", :gray)
626
661
  end
627
662
 
628
663
  # Fire `before` hooks from root down through the namespace chain.
@@ -703,7 +738,7 @@ class Hammer
703
738
  each_command(include_builtins: extended) { |path, c| print_full_block(path, c) unless c.desc.empty? }
704
739
  else
705
740
  Shell.say ''
706
- print_command_list(self, include_builtins: extended)
741
+ print_command_list(include_builtins: extended)
707
742
  end
708
743
  print_recipes_section if extended && root.instance_variable_get(:@hammer_binary)
709
744
  print_extras if extended
@@ -736,8 +771,8 @@ class Hammer
736
771
  Shell.say "Usage: #{program_name} #{prefix}:COMMAND [ARGS]", :cyan
737
772
  rows = []
738
773
  sibling = find_namespace_sibling(prefix)
739
- rows << [prefix, sibling] if sibling && !sibling.desc.empty?
740
- ns.each_command(prefix) { |path, c| rows << [path, c] unless c.desc.empty? }
774
+ rows << [prefix, sibling.to_h(prefix)] if sibling && !sibling.desc.empty?
775
+ ns.each_command(prefix) { |path, c| rows << [path, c.to_h(path)] unless c.desc.empty? }
741
776
  unless rows.empty?
742
777
  Shell.say ''
743
778
  Shell.say 'Commands:', :yellow
@@ -802,38 +837,30 @@ class Hammer
802
837
  Shell.say Hammer::STARTER_HAMMERFILE
803
838
  end
804
839
 
805
- def print_command_list(klass, prefix = nil, include_builtins: true)
806
- rows = []
807
- # Commands without a `desc` are hidden from listings but still
808
- # dispatchable + `hammer`-callable - useful for private helpers
809
- # invoked from `before` hooks or other commands (e.g. `:env`, `:app`).
810
- klass.each_command(prefix, include_builtins: include_builtins) { |full, c| rows << [full, c] unless c.desc.empty? }
811
- return if rows.empty?
812
-
813
- # group by "section" = everything between the view prefix and the
814
- # leaf name. Bare leaves go in :root.
815
- groups = rows.group_by { |full, _| section_for(full, prefix, klass) }
816
- width = rows.map { |full, _| full.length }.max
817
- first = true
818
-
819
- if (rooted = groups.delete(:root))
820
- Shell.say 'Commands:', :yellow
821
- emit_rows(rooted.sort_by { |full, _| [full.count(':'), full] }, width)
822
- first = false
823
- end
824
-
825
- groups.each do |section, items|
826
- Shell.say unless first
827
- first = false
828
- Shell.say "#{section}:", :yellow
829
- emit_rows(items.sort_by { |full, _| [full.count(':'), full] }, width)
840
+ # Pure rendering off `export_spec` - the same grouped structure
841
+ # `h:json` emits, so the listing and the JSON can never drift.
842
+ # `export_spec` already does the work: drops hidden (no-`desc`)
843
+ # tasks, prunes the `h:` tree unless include_builtins, groups by
844
+ # first namespace segment ("__root" for bare tasks), orders "__root"
845
+ # first, and sorts each group by [depth, name].
846
+ def print_command_list(include_builtins: true)
847
+ groups = export_spec(include_builtins: include_builtins)[:commands]
848
+ return if groups.empty?
849
+
850
+ width = groups.values.flat_map(&:keys).map(&:length).max
851
+ groups.each_with_index do |(section, tasks), i|
852
+ Shell.say unless i.zero?
853
+ Shell.say(section == '__root' ? 'Commands:' : "#{section}:", :yellow)
854
+ emit_rows(tasks.to_a, width)
830
855
  end
831
856
  end
832
857
 
858
+ # `rows` is an array of [full_path, task_meta] - the per-task hashes
859
+ # from `Command#to_h` (also used to render namespace listings).
833
860
  def emit_rows(rows, width)
834
- rows.each do |full, c|
835
- brief = c.alts.empty? ? c.brief : "#{c.brief} (alt: #{c.alts.join(', ')})"
836
- brief = "#{brief} #{Shell.paint('(redefined)', :yellow)}" if c.prev_location
861
+ rows.each do |full, t|
862
+ brief = t[:alts].empty? ? t[:brief] : "#{t[:brief]} (alt: #{t[:alts].join(', ')})"
863
+ brief = "#{brief} #{Shell.paint('(redefined)', :yellow)}" if t[:redefined]
837
864
  Shell.say " #{program_name} #{full.ljust(width)} # #{brief}"
838
865
  end
839
866
  end
@@ -1047,6 +1074,7 @@ class Hammer
1047
1074
  def self.cli(argv = ARGV)
1048
1075
  argv = argv.dup
1049
1076
  force_system = !!argv.delete('--system')
1077
+ launch_gui = !!argv.delete('--gui')
1050
1078
 
1051
1079
  # Shebang invocation: `hammer /path/to/script ...args` (kernel passes
1052
1080
  # the script path as argv[0] for `#!/usr/bin/env hammer` files).
@@ -1060,6 +1088,12 @@ class Hammer
1060
1088
  end
1061
1089
 
1062
1090
  path = force_system ? nil : find_hammerfile(Dir.pwd)
1091
+
1092
+ # `hammer --gui` opens the native macOS runner pointed at this project
1093
+ # (the Hammerfile's dir, or cwd when none was found). The CLI just
1094
+ # launches the bundled app and returns.
1095
+ return launch_gui!(path ? File.dirname(path) : Dir.pwd) if launch_gui
1096
+
1063
1097
  unless path
1064
1098
  # No Hammerfile (or --system) - all built-ins are reachable. Bare
1065
1099
  # `hammer`, `hammer h:recipes`, `hammer h:update`, `hammer h:agents`,
@@ -1188,4 +1222,21 @@ class Hammer
1188
1222
  end
1189
1223
  end
1190
1224
 
1225
+ # Spawn the vendored macOS GUI (gui/Hammer.app), pointed at the project
1226
+ # dir and this hammer binary. Launched directly (not via `open`) so it
1227
+ # inherits the caller's environment - the GUI shells back out to this
1228
+ # same `hammer` for `h:json` and task runs, and that needs the same PATH.
1229
+ def self.launch_gui!(project_dir)
1230
+ bin = File.expand_path('../gui/Hammer.app/Contents/MacOS/HammerGUI', __dir__)
1231
+ unless File.executable?(bin)
1232
+ Shell.print_error "GUI app not found at #{bin}"
1233
+ Shell.say 'build it: ./gui/HammerGUI/build_app.sh', :yellow
1234
+ exit 1
1235
+ end
1236
+ hammer_bin = (File.realpath($PROGRAM_NAME) rescue File.expand_path($PROGRAM_NAME))
1237
+ pid = Process.spawn(bin, '--project', File.expand_path(project_dir), '--hammer', hammer_bin)
1238
+ Process.detach(pid)
1239
+ Shell.say "launched Hammer GUI for #{project_dir} (pid #{pid})", :green
1240
+ end
1241
+
1191
1242
  end
@@ -142,8 +142,8 @@ helpers do
142
142
  if message.empty?
143
143
  run 'git reset --mixed'
144
144
  exit
145
- elsif message.length < 5
146
- say 'Please add better commit message, min length 5 chars', :red
145
+ elsif message.length < 1
146
+ say 'Please add better commit message, min length 1 chars', :red
147
147
  next
148
148
  else
149
149
  bump_version
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.13
4
+ version: 0.3.14
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-06-05 00:00:00.000000000 Z
11
+ date: 2026-06-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: minitest
@@ -36,6 +36,8 @@ files:
36
36
  - "./AGENTS.md"
37
37
  - "./README.md"
38
38
  - "./bin/hammer"
39
+ - "./gui/Hammer.app/Contents/Info.plist"
40
+ - "./gui/Hammer.app/Contents/MacOS/HammerGUI"
39
41
  - "./lib/hammer/builder.rb"
40
42
  - "./lib/hammer/builtins.rb"
41
43
  - "./lib/hammer/command.rb"