brute_cli 0.1.2 → 0.1.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: 5d935729b509640bc8f34ab0250ba7aa4760c66f600e2f8ce4dc74ad2e9e756a
4
- data.tar.gz: 7b61d6668976f55bfd334d718b6beb274ffb29a6cd85de1cd4178b7da59e211b
3
+ metadata.gz: 6f33b8ddd973b65aaa7541e4105c3d87d52f39b22ea9500d613a3b3e193024ac
4
+ data.tar.gz: 4c02c1b1cf76a2564062a120e235212ea86304366901bb1f574fa95bbecc95b3
5
5
  SHA512:
6
- metadata.gz: 750488466360f678c3ec34a637980dbb727e64f8b24cd27e86f015503a14b9dedd36da7eca002f905f4e7ddd191b42a9fe2d163bad229c83765d2a26de9f8ecf
7
- data.tar.gz: 7917a11383995c907541543b4893f2067ab50ae16389b56054255e866a3f35f7b223e3b37d69c4b4b97876e245c9ef1bb843567598cf05024b497fde2fe43880
6
+ metadata.gz: 5d0bcbadf66e5d7d0abc8445f89c5bdb153b44eb08effad501f43bf5847d6475b476e5215ad1be4fbdf712af83e947002037e78d7d50462b1b139e79d825a9d9
7
+ data.tar.gz: e93b600e57fda32304b8ec61c097f82cd4ecc06e659eba08dcbcb6f0c6a74d1d54012fd0907c70ea94aea5e0cb8e3d351c4b51925fb3f015ca60f815f27f0be6
data/exe/brute CHANGED
@@ -11,10 +11,13 @@ OptionParser.new do |opts|
11
11
  opts.on("-d", "--directory DIR", "Working directory") { |d| options[:cwd] = File.expand_path(d) }
12
12
  opts.on("-s", "--session ID", "Resume a session") { |id| options[:session_id] = id }
13
13
  opts.on("--list-sessions", "List saved sessions") { options[:list] = true }
14
+ opts.on("--theme NAME", "Color theme (#{BruteCLI::THEMES.keys.join(', ')})") { |t| options[:theme] = t }
14
15
  opts.on("-v", "--version", "Show version") { puts "brute #{Brute::VERSION}"; exit }
15
16
  opts.on("-h", "--help", "Show help") { puts opts; exit }
16
17
  end.parse!
17
18
 
19
+ BruteCLI.apply_theme!(options[:theme]) if options[:theme]
20
+
18
21
  # ── List sessions ──
19
22
 
20
23
  if options[:list]
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module BruteCLI
6
+ # Thin wrapper around the `bat` command for syntax-highlighted terminal output.
7
+ # Provides two rendering modes: one for unified diffs and one for markdown prose.
8
+ module Bat
9
+ BAT_BIN = ENV.fetch("BRUTE_BAT_BIN", "bat")
10
+
11
+ COMMON_FLAGS = %w[
12
+ --color=always
13
+ --paging=never
14
+ ].freeze
15
+
16
+ # Render a unified diff with line numbers and a grid border.
17
+ #
18
+ # BruteCLI::Bat.diff_mode(patch_text, width: 100)
19
+ #
20
+ def self.diff_mode(text, width: 80)
21
+ run(text, language: "diff", style: "numbers,grid", width: width)
22
+ end
23
+
24
+ # Render markdown source with syntax highlighting (headers, bold, fenced
25
+ # code blocks, etc.) — no extra decorations so it reads like prose.
26
+ #
27
+ # BruteCLI::Bat.markdown_mode(md_text, width: 120)
28
+ #
29
+ def self.markdown_mode(text, width: 80)
30
+ run(text, language: "markdown", style: "plain", width: width)
31
+ end
32
+
33
+ # Returns true if the bat binary is found on PATH.
34
+ def self.available?
35
+ return @available if defined?(@available)
36
+ @available = ENV["PATH"].to_s.split(File::PATH_SEPARATOR).any? { |dir| File.executable?(File.join(dir, BAT_BIN)) }
37
+ end
38
+
39
+ # Low-level: pipe +text+ through bat with arbitrary options.
40
+ def self.run(text, language:, style:, width: 80)
41
+ cmd = [
42
+ BAT_BIN,
43
+ *COMMON_FLAGS,
44
+ "--language=#{language}",
45
+ "--style=#{style}",
46
+ "--terminal-width=#{width}",
47
+ ]
48
+
49
+ stdout, status = Open3.capture2(*cmd, stdin_data: text)
50
+
51
+ if status.success?
52
+ stdout
53
+ else
54
+ # If bat exits non-zero, return the raw text so we never swallow output.
55
+ text
56
+ end
57
+ rescue Errno::ENOENT
58
+ unless @bat_missing_warned
59
+ msg = " bat not found — diff syntax highlighting unavailable.\n" \
60
+ " Install: https://github.com/sharkdp/bat#installation "
61
+ $stderr.puts msg.colorize(background: :red, color: :white)
62
+ @bat_missing_warned = true
63
+ end
64
+ text
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BruteCLI
4
+ # Registry of slash commands available in the REPL.
5
+ #
6
+ # Each command maps to a method name on the REPL instance.
7
+ # Reline's completion_proc uses +names+ to offer autocomplete suggestions
8
+ # when the user types "/" at the start of a line.
9
+ #
10
+ module Commands
11
+ Entry = Struct.new(:name, :description, :method_name, keyword_init: true)
12
+
13
+ REGISTRY = [
14
+ Entry.new(name: "/menu", description: "Open main menu", method_name: :cmd_menu),
15
+ Entry.new(name: "/model", description: "Change model", method_name: :cmd_model),
16
+ Entry.new(name: "/provider", description: "Change provider", method_name: :cmd_provider),
17
+ Entry.new(name: "/help", description: "Show available commands", method_name: :cmd_help),
18
+ Entry.new(name: "/compact", description: "Compact conversation", method_name: :cmd_compact),
19
+ Entry.new(name: "/exit", description: "Exit brute", method_name: :cmd_exit),
20
+ ].freeze
21
+
22
+ # All command names, for Reline completion.
23
+ def self.names
24
+ REGISTRY.map(&:name)
25
+ end
26
+
27
+ # Does this input look like a slash command?
28
+ def self.match?(input)
29
+ input.strip.start_with?("/")
30
+ end
31
+
32
+ # Find the matching Entry for the given input, or nil.
33
+ def self.find(input)
34
+ cmd = input.strip.split(/\s+/, 2).first
35
+ REGISTRY.detect { |e| e.name == cmd }
36
+ end
37
+ end
38
+ end
@@ -27,5 +27,7 @@ module BruteCLI
27
27
  WRITING = find('writing_hand')
28
28
  ROBOT = find('robot')
29
29
  FOLDER = find('file_folder')
30
+ SQUARE = find('white_large_square')
31
+ ARROWS = find('arrows_counterclockwise')
30
32
  end
31
33
  end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BruteCLI
4
+ # A simple state-machine menu system powered by fzf.
5
+ #
6
+ # Each menu is a named bag of choices. Each choice points to either:
7
+ # - a Symbol → the name of the next menu to show
8
+ # - nil → exit the menu loop (Ctrl-C / Escape does the same)
9
+ # - anything else → returned to the caller as an "action" value
10
+ #
11
+ # The engine is just: while current.is_a?(Symbol) { current = show(menu) }
12
+ #
13
+ # Menus can be static (block evaluated at definition time) or dynamic
14
+ # (block with arity > 0, evaluated fresh each time the menu is shown).
15
+ #
16
+ # Titles can be strings or callables (lambdas) for dynamic labels.
17
+ #
18
+ # Example:
19
+ #
20
+ # app = BruteCLI::FzfMenu.new do
21
+ # menu :main, "Cool Menu" do
22
+ # choice "Status", :status
23
+ # choice "Manage", :manage
24
+ # choice "Exit", nil
25
+ # end
26
+ #
27
+ # menu :status, "Status" do
28
+ # choice "Back", :main
29
+ # end
30
+ #
31
+ # # Dynamic menu — block receives a fresh Menu at render time
32
+ # menu :models, -> { "Select Model" } do |m|
33
+ # models.each { |id| m.choice(id, [:set_model, id]) }
34
+ # m.choice "Back", :main
35
+ # end
36
+ # end
37
+ #
38
+ # result = app.call # starts at :main
39
+ # result = app.call(:models) # jump straight to :models
40
+ #
41
+ class FzfMenu
42
+ class Menu
43
+ attr_reader :title, :choices
44
+
45
+ def initialize(title = nil)
46
+ @title = title
47
+ @choices = []
48
+ end
49
+
50
+ def resolved_title
51
+ @title.respond_to?(:call) ? @title.call : @title.to_s
52
+ end
53
+
54
+ def choice(label, target)
55
+ @choices << [label, target]
56
+ end
57
+ end
58
+
59
+ def initialize(&definition)
60
+ @menus = {} # name → Menu (static)
61
+ @dynamic = {} # name → [title, block] (dynamic, built at render time)
62
+ instance_eval(&definition) if definition
63
+ end
64
+
65
+ # Define a named menu.
66
+ #
67
+ # Static (block with no params — evaluated once at definition):
68
+ # menu :main, "Title" do
69
+ # choice "Foo", :foo
70
+ # end
71
+ #
72
+ # Dynamic (block with one param — evaluated each time the menu is shown):
73
+ # menu :models, "Title" do |m|
74
+ # m.choice "Foo", :foo
75
+ # end
76
+ #
77
+ def menu(name, title = nil, &block)
78
+ if block.arity > 0
79
+ @dynamic[name] = [title, block]
80
+ else
81
+ m = Menu.new(title)
82
+ m.instance_eval(&block)
83
+ @menus[name] = m
84
+ end
85
+ end
86
+
87
+ # Run the menu loop starting at the given menu name.
88
+ # Returns the final non-Symbol value chosen (or nil on escape/Ctrl-C).
89
+ def call(start = nil)
90
+ start ||= @menus.keys.first || @dynamic.keys.first
91
+ current = start
92
+
93
+ while current.is_a?(Symbol)
94
+ current = show(resolve_menu(current))
95
+ end
96
+
97
+ current
98
+ end
99
+
100
+ # All registered menu names (static + dynamic).
101
+ def menu_names
102
+ @menus.keys | @dynamic.keys
103
+ end
104
+
105
+ private
106
+
107
+ def resolve_menu(name)
108
+ if @dynamic.key?(name)
109
+ title, builder = @dynamic[name]
110
+ m = Menu.new(title)
111
+ builder.call(m)
112
+ m
113
+ else
114
+ @menus.fetch(name) { raise KeyError, "Unknown menu: #{name.inspect}" }
115
+ end
116
+ end
117
+
118
+ def show(menu)
119
+ labels = menu.choices.map(&:first)
120
+ return nil if labels.empty?
121
+
122
+ selected = fzf(labels, prompt: menu.resolved_title)
123
+ return nil unless selected
124
+
125
+ menu.choices.detect { |label, _| label == selected }&.last
126
+ end
127
+
128
+ def fzf(items, prompt:)
129
+ unless fzf_available?
130
+ warn "fzf not found in PATH. Install fzf to use interactive menus."
131
+ return nil
132
+ end
133
+
134
+ cmd = ["fzf", "--prompt=#{prompt} › ", "--height=~#{items.size + 2}", "--reverse", "--no-info"]
135
+ IO.popen(cmd, "r+") do |io|
136
+ io.puts items
137
+ io.close_write
138
+ io.gets&.strip
139
+ end
140
+ rescue Errno::ENOENT
141
+ warn "fzf not found in PATH. Install fzf to use interactive menus."
142
+ nil
143
+ end
144
+
145
+ def fzf_available?
146
+ return @fzf_available if defined?(@fzf_available)
147
+ @fzf_available = ENV["PATH"].to_s.split(File::PATH_SEPARATOR).any? { |dir| File.executable?(File.join(dir, "fzf")) }
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,334 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bubbletea"
4
+ require "lipgloss"
5
+ require "bubbles"
6
+
7
+ module BruteCLI
8
+ # Full-screen alternate-buffer question form built directly on Bubbletea.
9
+ #
10
+ # Opens a new terminal screen (like fzf/vim) via alt_screen: true,
11
+ # renders select/multi-select forms with Lipgloss styling, handles
12
+ # keyboard input through Bubbletea's raw-mode event loop (termios),
13
+ # and restores the original screen when done.
14
+ class QuestionScreen
15
+ include Bubbletea::Model
16
+
17
+ # ── Styles ──
18
+
19
+ ACCENT = "#FFCC00"
20
+ DIM = "#666666"
21
+ SELECTED = "#00FF88"
22
+ HELP_COLOR = "#555555"
23
+
24
+ # ── Public API ──
25
+
26
+ def self.ask(questions)
27
+ screen = new(questions)
28
+ screen.run
29
+ end
30
+
31
+ def initialize(questions)
32
+ @questions = questions.map { |q| normalize(q) }
33
+ @question_idx = 0
34
+ @cursor = 0
35
+ @selected = Set.new # for multi-select
36
+ @answers = [] # collected answers per question
37
+ @state = :selecting # :selecting | :other_input | :done | :aborted
38
+ @text_input = Bubbles::TextInput.new
39
+ @text_input.prompt = " > "
40
+ @text_input.placeholder = "Type your response..."
41
+ @width = 80
42
+ @height = 24
43
+
44
+ build_styles
45
+ end
46
+
47
+ def run
48
+ # Release the terminal from reline before bubbletea takes over.
49
+ # Reline may hold termios flags (cooked mode, echo, signal handling)
50
+ # that prevent bubbletea's Go FFI from entering raw mode and reading
51
+ # escape sequences (arrow keys). deprep_terminal restores the
52
+ # terminal to its pre-reline state so bubbletea gets a clean slate.
53
+ reline_active = defined?(Reline) && Reline.respond_to?(:deprep_terminal)
54
+ Reline.deprep_terminal if reline_active
55
+
56
+ Bubbletea.run(self, alt_screen: true)
57
+ @state == :aborted ? @questions.map { [] } : @answers
58
+ ensure
59
+ # Re-prep reline so the REPL prompt works when we return.
60
+ Reline.prep_terminal if reline_active
61
+ end
62
+
63
+ # ── Bubbletea::Model interface ──
64
+
65
+ def init
66
+ [self, nil]
67
+ end
68
+
69
+ def update(message)
70
+ case message
71
+ when Bubbletea::WindowSizeMessage
72
+ @width = message.width
73
+ @height = message.height
74
+ [self, nil]
75
+ when Bubbletea::KeyMessage
76
+ handle_key(message)
77
+ else
78
+ [self, nil]
79
+ end
80
+ end
81
+
82
+ def view
83
+ return "" if @state == :done || @state == :aborted
84
+
85
+ case @state
86
+ when :selecting
87
+ view_selecting
88
+ when :other_input
89
+ view_other_input
90
+ else
91
+ ""
92
+ end
93
+ end
94
+
95
+ private
96
+
97
+ # ── Key handling ──
98
+
99
+ def handle_key(msg)
100
+ case @state
101
+ when :selecting
102
+ handle_selecting_key(msg)
103
+ when :other_input
104
+ handle_other_input_key(msg)
105
+ else
106
+ [self, nil]
107
+ end
108
+ end
109
+
110
+ def handle_selecting_key(msg)
111
+ opts = current_options_with_other
112
+
113
+ # Match on KeyMessage type methods first (reliable across Go FFI
114
+ # versions), then fall back to string name for character keys.
115
+ if msg.to_s == "ctrl+c" || msg.to_s == "q"
116
+ @state = :aborted
117
+ return [self, Bubbletea.quit]
118
+ end
119
+
120
+ if msg.up? || msg.to_s == "k"
121
+ @cursor = (@cursor - 1) % opts.size
122
+ return [self, nil]
123
+ end
124
+
125
+ if msg.down? || msg.to_s == "j"
126
+ @cursor = (@cursor + 1) % opts.size
127
+ return [self, nil]
128
+ end
129
+
130
+ if msg.space? || msg.to_s == "x"
131
+ if current_multiple?
132
+ if @selected.include?(@cursor)
133
+ @selected.delete(@cursor)
134
+ else
135
+ @selected.add(@cursor)
136
+ end
137
+ end
138
+ return [self, nil]
139
+ end
140
+
141
+ if msg.enter?
142
+ return confirm_selection
143
+ end
144
+
145
+ [self, nil]
146
+ end
147
+
148
+ def handle_other_input_key(msg)
149
+ if msg.enter?
150
+ val = @text_input.value.strip
151
+ current_answer = pending_selected_labels
152
+ current_answer << val unless val.empty?
153
+ return finalize_answer(current_answer)
154
+ end
155
+
156
+ if msg.esc?
157
+ @state = :selecting
158
+ return [self, nil]
159
+ end
160
+
161
+ if msg.to_s == "ctrl+c"
162
+ @state = :aborted
163
+ return [self, Bubbletea.quit]
164
+ end
165
+
166
+ @text_input, cmd = @text_input.update(msg)
167
+ [self, cmd]
168
+ end
169
+
170
+ # ── Selection logic ──
171
+
172
+ def confirm_selection
173
+ opts = current_options_with_other
174
+
175
+ if current_multiple?
176
+ labels = @selected.sort.map { |i| opts[i] }
177
+ else
178
+ labels = [opts[@cursor]]
179
+ end
180
+
181
+ if labels.include?(:other)
182
+ labels.delete(:other)
183
+ @state = :other_input
184
+ @text_input.value = ""
185
+ @text_input.focus
186
+ @pending_labels = labels.map { |l| l.is_a?(Hash) ? l[:value] : l }
187
+ return [self, nil]
188
+ end
189
+
190
+ answer = labels.map { |l| l.is_a?(Hash) ? l[:value] : l.to_s }
191
+ finalize_answer(answer)
192
+ end
193
+
194
+ def pending_selected_labels
195
+ (@pending_labels || []).map(&:to_s)
196
+ end
197
+
198
+ def finalize_answer(answer)
199
+ @answers << answer.map(&:to_s)
200
+
201
+ # Advance to next question
202
+ @question_idx += 1
203
+ if @question_idx >= @questions.size
204
+ @state = :done
205
+ [self, Bubbletea.quit]
206
+ else
207
+ @cursor = 0
208
+ @selected = Set.new
209
+ @state = :selecting
210
+ [self, nil]
211
+ end
212
+ end
213
+
214
+ # ── View rendering ──
215
+
216
+ def view_selecting
217
+ q = current_question
218
+ opts = current_options_with_other
219
+ lines = []
220
+
221
+ # Header
222
+ lines << ""
223
+ lines << @header_style.render(" #{q['header'] || 'Question'}")
224
+ lines << ""
225
+
226
+ # Question text
227
+ lines << @question_style.render(" #{q['question']}")
228
+ lines << ""
229
+
230
+ # Options
231
+ opts.each_with_index do |opt, i|
232
+ is_cursor = i == @cursor
233
+ label = opt == :other ? "Other (custom answer)" : "#{opt[:label]} -- #{opt[:desc]}"
234
+
235
+ if current_multiple?
236
+ check = @selected.include?(i) ? "[x]" : "[ ]"
237
+ prefix = is_cursor ? " > #{check} " : " #{check} "
238
+ else
239
+ prefix = is_cursor ? " > " : " "
240
+ end
241
+
242
+ text = "#{prefix}#{label}"
243
+ lines << if is_cursor
244
+ @cursor_style.render(text)
245
+ else
246
+ @option_style.render(text)
247
+ end
248
+ end
249
+
250
+ # Help bar
251
+ lines << ""
252
+ help = if current_multiple?
253
+ " up/down navigate | space toggle | enter confirm | q quit"
254
+ else
255
+ " up/down navigate | enter select | q quit"
256
+ end
257
+ lines << @help_style.render(help)
258
+
259
+ # Progress
260
+ if @questions.size > 1
261
+ lines << @help_style.render(" question #{@question_idx + 1}/#{@questions.size}")
262
+ end
263
+
264
+ content = lines.join("\n")
265
+
266
+ # Center vertically
267
+ content_lines = content.split("\n").size
268
+ pad = [(@height - content_lines) / 2, 1].max
269
+ ("\n" * pad) + content
270
+ end
271
+
272
+ def view_other_input
273
+ q = current_question
274
+ lines = []
275
+
276
+ lines << ""
277
+ lines << @header_style.render(" #{q['header'] || 'Question'}")
278
+ lines << ""
279
+ lines << @question_style.render(" Your answer:")
280
+ lines << ""
281
+ lines << " #{@text_input.view}"
282
+ lines << ""
283
+ lines << @help_style.render(" enter submit | esc back | ctrl+c quit")
284
+
285
+ content = lines.join("\n")
286
+ content_lines = content.split("\n").size
287
+ pad = [(@height - content_lines) / 2, 1].max
288
+ ("\n" * pad) + content
289
+ end
290
+
291
+ # ── Helpers ──
292
+
293
+ def normalize(q)
294
+ q.transform_keys(&:to_s).tap do |h|
295
+ h["options"] = (h["options"] || []).map { |o| o.transform_keys(&:to_s) }
296
+ end
297
+ end
298
+
299
+ def current_question
300
+ @questions[@question_idx]
301
+ end
302
+
303
+ def current_multiple?
304
+ current_question["multiple"]
305
+ end
306
+
307
+ def current_options_with_other
308
+ opts = current_question["options"].map do |o|
309
+ { label: o["label"], desc: o["description"], value: o["label"] }
310
+ end
311
+ opts << :other
312
+ opts
313
+ end
314
+
315
+ def build_styles
316
+ @header_style = Lipgloss::Style.new
317
+ .bold(true)
318
+ .foreground(ACCENT)
319
+
320
+ @question_style = Lipgloss::Style.new
321
+ .bold(true)
322
+
323
+ @cursor_style = Lipgloss::Style.new
324
+ .foreground(SELECTED)
325
+ .bold(true)
326
+
327
+ @option_style = Lipgloss::Style.new
328
+
329
+ @help_style = Lipgloss::Style.new
330
+ .foreground(HELP_COLOR)
331
+ .italic(true)
332
+ end
333
+ end
334
+ end