r2ui 0.1.0 → 0.2.0
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/CHANGELOG.md +64 -0
- data/README.md +79 -0
- data/lib/r2ui/app.rb +311 -34
- data/lib/r2ui/canvas.rb +135 -14
- data/lib/r2ui/cli/builder.rb +72 -0
- data/lib/r2ui/cli/context.rb +45 -0
- data/lib/r2ui/cli/definition.rb +174 -0
- data/lib/r2ui/cli/ext/ask.rb +133 -0
- data/lib/r2ui/cli/ext/box.rb +112 -0
- data/lib/r2ui/cli/ext/choose.rb +162 -0
- data/lib/r2ui/cli/ext/choose_many.rb +241 -0
- data/lib/r2ui/cli/ext/color_option.rb +64 -0
- data/lib/r2ui/cli/ext/completion.rb +520 -0
- data/lib/r2ui/cli/ext/config.rb +179 -0
- data/lib/r2ui/cli/ext/confirm.rb +110 -0
- data/lib/r2ui/cli/ext/dashboard.rb +105 -0
- data/lib/r2ui/cli/ext/diff.rb +195 -0
- data/lib/r2ui/cli/ext/editor.rb +94 -0
- data/lib/r2ui/cli/ext/env.rb +108 -0
- data/lib/r2ui/cli/ext/examples.rb +62 -0
- data/lib/r2ui/cli/ext/filter.rb +269 -0
- data/lib/r2ui/cli/ext/format.rb +104 -0
- data/lib/r2ui/cli/ext/list.rb +117 -0
- data/lib/r2ui/cli/ext/log.rb +89 -0
- data/lib/r2ui/cli/ext/pager.rb +97 -0
- data/lib/r2ui/cli/ext/pairs.rb +62 -0
- data/lib/r2ui/cli/ext/parallel.rb +274 -0
- data/lib/r2ui/cli/ext/password.rb +158 -0
- data/lib/r2ui/cli/ext/progress.rb +330 -0
- data/lib/r2ui/cli/ext/reference.rb +129 -0
- data/lib/r2ui/cli/ext/spinner.rb +165 -0
- data/lib/r2ui/cli/ext/summary.rb +63 -0
- data/lib/r2ui/cli/ext/table.rb +141 -0
- data/lib/r2ui/cli/ext/tasks.rb +216 -0
- data/lib/r2ui/cli/ext/trace.rb +64 -0
- data/lib/r2ui/cli/ext/tree.rb +114 -0
- data/lib/r2ui/cli/ext/version.rb +67 -0
- data/lib/r2ui/cli/extension.rb +129 -0
- data/lib/r2ui/cli/help.rb +115 -0
- data/lib/r2ui/cli/helpers.rb +20 -0
- data/lib/r2ui/cli/live.rb +150 -0
- data/lib/r2ui/cli/parser.rb +165 -0
- data/lib/r2ui/cli/prompt.rb +133 -0
- data/lib/r2ui/cli/runner.rb +86 -0
- data/lib/r2ui/cli/shell.rb +185 -0
- data/lib/r2ui/cli/testing.rb +49 -0
- data/lib/r2ui/cli.rb +129 -0
- data/lib/r2ui/compat/bubbletea/LICENSE-bubbletea.txt +21 -0
- data/lib/r2ui/compat/bubbletea/ansi.rb +424 -0
- data/lib/r2ui/compat/bubbletea/commands.rb +143 -0
- data/lib/r2ui/compat/bubbletea/input.rb +269 -0
- data/lib/r2ui/compat/bubbletea/input_reader.rb +66 -0
- data/lib/r2ui/compat/bubbletea/messages.rb +251 -0
- data/lib/r2ui/compat/bubbletea/model.rb +65 -0
- data/lib/r2ui/compat/bubbletea/native.rb +254 -0
- data/lib/r2ui/compat/bubbletea/renderer.rb +192 -0
- data/lib/r2ui/compat/bubbletea/runner.rb +425 -0
- data/lib/r2ui/compat/bubbletea/terminal.rb +230 -0
- data/lib/r2ui/compat/bubbletea/version.rb +8 -0
- data/lib/r2ui/compat/bubbletea/width_table.rb +228 -0
- data/lib/r2ui/compat/bubbletea.rb +16 -0
- data/lib/r2ui/compat/lipgloss/border.rb +17 -0
- data/lib/r2ui/compat/lipgloss/color.rb +78 -0
- data/lib/r2ui/compat/lipgloss/color_blend.rb +166 -0
- data/lib/r2ui/compat/lipgloss/colorful.rb +585 -0
- data/lib/r2ui/compat/lipgloss/layout.rb +461 -0
- data/lib/r2ui/compat/lipgloss/list.rb +85 -0
- data/lib/r2ui/compat/lipgloss/position.rb +30 -0
- data/lib/r2ui/compat/lipgloss/style/border.rb +134 -0
- data/lib/r2ui/compat/lipgloss/style.rb +1062 -0
- data/lib/r2ui/compat/lipgloss/table/render.rb +518 -0
- data/lib/r2ui/compat/lipgloss/table.rb +185 -0
- data/lib/r2ui/compat/lipgloss/termenv.rb +768 -0
- data/lib/r2ui/compat/lipgloss/text/unicode_tables.rb +428 -0
- data/lib/r2ui/compat/lipgloss/text.rb +1555 -0
- data/lib/r2ui/compat/lipgloss/tree/go_tree.rb +243 -0
- data/lib/r2ui/compat/lipgloss/tree.rb +105 -0
- data/lib/r2ui/compat/lipgloss/version.rb +15 -0
- data/lib/r2ui/compat/lipgloss.rb +64 -0
- data/lib/r2ui/compat/load_path/bubbletea.rb +4 -0
- data/lib/r2ui/compat/load_path/lipgloss.rb +4 -0
- data/lib/r2ui/component.rb +67 -0
- data/lib/r2ui/context.rb +87 -0
- data/lib/r2ui/drop_in.rb +16 -0
- data/lib/r2ui/dsl/column.rb +11 -3
- data/lib/r2ui/dsl/dashboard.rb +45 -9
- data/lib/r2ui/dsl/resource.rb +12 -0
- data/lib/r2ui/ext/after.rb +54 -0
- data/lib/r2ui/ext/async.rb +81 -0
- data/lib/r2ui/ext/batch_sequence.rb +54 -0
- data/lib/r2ui/ext/countdown.rb +52 -0
- data/lib/r2ui/ext/data_table.rb +71 -0
- data/lib/r2ui/ext/every.rb +66 -0
- data/lib/r2ui/ext/exec.rb +116 -0
- data/lib/r2ui/ext/file_picker.rb +78 -0
- data/lib/r2ui/ext/focus_report.rb +69 -0
- data/lib/r2ui/ext/help.rb +62 -0
- data/lib/r2ui/ext/keys.rb +64 -0
- data/lib/r2ui/ext/list.rb +145 -0
- data/lib/r2ui/ext/messages.rb +87 -0
- data/lib/r2ui/ext/mouse.rb +134 -0
- data/lib/r2ui/ext/paginator.rb +86 -0
- data/lib/r2ui/ext/paste.rb +86 -0
- data/lib/r2ui/ext/println.rb +21 -0
- data/lib/r2ui/ext/progress.rb +85 -0
- data/lib/r2ui/ext/refresh.rb +56 -0
- data/lib/r2ui/ext/resize.rb +62 -0
- data/lib/r2ui/ext/screen.rb +47 -0
- data/lib/r2ui/ext/screen_mode.rb +63 -0
- data/lib/r2ui/ext/spinner.rb +59 -0
- data/lib/r2ui/ext/state.rb +45 -0
- data/lib/r2ui/ext/stopwatch.rb +67 -0
- data/lib/r2ui/ext/suspend.rb +56 -0
- data/lib/r2ui/ext/text_area.rb +73 -0
- data/lib/r2ui/ext/text_input.rb +83 -0
- data/lib/r2ui/ext/theme.rb +112 -0
- data/lib/r2ui/ext/view.rb +37 -0
- data/lib/r2ui/ext/viewport.rb +92 -0
- data/lib/r2ui/ext/window_title.rb +51 -0
- data/lib/r2ui/extension.rb +199 -0
- data/lib/r2ui/history.rb +4 -1
- data/lib/r2ui/keys.rb +35 -8
- data/lib/r2ui/motion.rb +169 -0
- data/lib/r2ui/panel_state.rb +46 -2
- data/lib/r2ui/renderer.rb +87 -22
- data/lib/r2ui/version.rb +1 -1
- data/lib/r2ui/widgets/box.rb +6 -3
- data/lib/r2ui/widgets/glyphs.rb +149 -0
- data/lib/r2ui/widgets/table.rb +109 -25
- data/lib/r2ui.rb +12 -1
- metadata +119 -4
- data/lib/r2ui/terminal.rb +0 -53
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module R2UI
|
|
4
|
+
module CLI
|
|
5
|
+
# How prompts run. On an interactive shell a prompt is a small Bubbletea model run inline (no
|
|
6
|
+
# alt screen) on r2ui's engine: raw mode while it asks, its last view (the answered line) left
|
|
7
|
+
# in the scrollback, the terminal restored on every exit path. Prompt stories build their model
|
|
8
|
+
# on Prompt::Model, hosting a bubbles component when one fits (TextInput for ask/password, List
|
|
9
|
+
# for choose; `CLI.require_bubbles!` first).
|
|
10
|
+
#
|
|
11
|
+
# Off a terminal a prompt reads one line instead (`Prompt.read_line`): asking on stderr when a
|
|
12
|
+
# person can still type (stdin is a terminal, stdout piped), silently from a pipe otherwise
|
|
13
|
+
# (`yes | tool`, `echo name | tool`), nil at end of input so the prompt falls back to its default.
|
|
14
|
+
module Prompt
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# Runs `model` until it quits; returns the final model. ctrl+c raises Interrupt after the
|
|
18
|
+
# terminal is restored (the model sets `interrupted`).
|
|
19
|
+
def run(shell, model)
|
|
20
|
+
runner = InlineRunner.new(model, shell)
|
|
21
|
+
runner.run
|
|
22
|
+
model = runner.model
|
|
23
|
+
raise Interrupt if model.respond_to?(:interrupted?) && model.interrupted?
|
|
24
|
+
|
|
25
|
+
model
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def read_line(shell, prompt)
|
|
29
|
+
if shell.input_tty?
|
|
30
|
+
shell.error.write(prompt)
|
|
31
|
+
shell.error.flush if shell.error.respond_to?(:flush)
|
|
32
|
+
end
|
|
33
|
+
line = shell.input.gets
|
|
34
|
+
line&.chomp
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Bubbletea's runner on the shell's input and output (the upstream runner always uses the
|
|
38
|
+
# process's stdin/stdout), inline.
|
|
39
|
+
class InlineRunner < Bubbletea::Runner
|
|
40
|
+
attr_reader :model
|
|
41
|
+
|
|
42
|
+
def initialize(model, shell)
|
|
43
|
+
super(model, alt_screen: false, fps: 30)
|
|
44
|
+
@shell = shell
|
|
45
|
+
@program = Bubbletea::Program.new(input: shell.input, output: shell.output)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# The runner ends with `print "\r\n"`: send it to the shell's output, not $stdout.
|
|
51
|
+
def print(*args) = @shell.output.print(*args)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# A base for prompt models: Bubbletea::Model plus the shared answer/interrupt bookkeeping.
|
|
55
|
+
#
|
|
56
|
+
# - ctrl+c and esc cancel (Prompt.run then raises Interrupt), before anything else sees them.
|
|
57
|
+
# - Other keys go to `key(name, msg)`; return `submit(value)` to answer, or nil to pass the
|
|
58
|
+
# key on to the hosted component.
|
|
59
|
+
# - `self.component = Bubbles::TextInput.new` hosts a bubbles model: its `init` runs with the
|
|
60
|
+
# prompt's, and it gets every message the prompt doesn't handle (keys `key` passes on, blink
|
|
61
|
+
# and other ticks, window size), its commands going out with the prompt's.
|
|
62
|
+
# Subclasses implement `view` (and usually `key`).
|
|
63
|
+
class Model
|
|
64
|
+
include Bubbletea::Model
|
|
65
|
+
|
|
66
|
+
attr_reader :value, :shell
|
|
67
|
+
attr_accessor :component
|
|
68
|
+
|
|
69
|
+
def initialize(shell)
|
|
70
|
+
@shell = shell
|
|
71
|
+
@done = false
|
|
72
|
+
@interrupted = false
|
|
73
|
+
@component = nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def init
|
|
77
|
+
return [self, nil] unless component.respond_to?(:init)
|
|
78
|
+
|
|
79
|
+
[self, adopt(component.init)]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def update(message)
|
|
83
|
+
if message.is_a?(Bubbletea::KeyMessage)
|
|
84
|
+
name = message.to_s
|
|
85
|
+
return cancel if %w[ctrl+c esc].include?(name)
|
|
86
|
+
|
|
87
|
+
result = key(name, message)
|
|
88
|
+
return result if result
|
|
89
|
+
end
|
|
90
|
+
forward(message)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def done? = @done
|
|
94
|
+
|
|
95
|
+
def interrupted? = @interrupted
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
# Prompts that only use a component may leave this out.
|
|
100
|
+
def key(_name, _message) = nil
|
|
101
|
+
|
|
102
|
+
# Sends a message to the hosted component; keeps the model it returns.
|
|
103
|
+
def forward(message)
|
|
104
|
+
return [self, nil] unless component
|
|
105
|
+
|
|
106
|
+
[self, adopt(component.update(message))]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# [model, cmd] or a bare cmd from a component's init/update → the cmd, keeping the model.
|
|
110
|
+
def adopt(result)
|
|
111
|
+
if result.is_a?(Array) && result.size == 2 && !result.first.is_a?(Bubbletea::Command)
|
|
112
|
+
@component = result.first unless result.first.nil?
|
|
113
|
+
result.last
|
|
114
|
+
else
|
|
115
|
+
result
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def submit(value)
|
|
120
|
+
@value = value
|
|
121
|
+
@done = true
|
|
122
|
+
[self, Bubbletea.quit]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def cancel
|
|
126
|
+
@interrupted = true
|
|
127
|
+
@done = true
|
|
128
|
+
[self, Bubbletea.quit]
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module R2UI
|
|
4
|
+
module CLI
|
|
5
|
+
# Runs one argv against a Program and turns every outcome into an exit code:
|
|
6
|
+
#
|
|
7
|
+
# 0 the command returned (or --help, or a group run without a subcommand: its help)
|
|
8
|
+
# 1 an error: CLI::Error, abort!, or anything else that escaped ("✖ message" on stderr;
|
|
9
|
+
# R2UI_TRACE=1 adds the backtrace)
|
|
10
|
+
# 2 a usage error: unknown/missing/bad option, argument or command, plus a --help hint
|
|
11
|
+
# 130 ctrl+c (Interrupt)
|
|
12
|
+
# n abort!(code: n), halt(n), or an on_error hook returning n
|
|
13
|
+
#
|
|
14
|
+
# A closed stdout (`tool | head`) ends quietly with 0.
|
|
15
|
+
class Runner
|
|
16
|
+
def initialize(program, shell:)
|
|
17
|
+
@program = program
|
|
18
|
+
@shell = shell
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(argv)
|
|
22
|
+
parser = Parser.new(@program)
|
|
23
|
+
invocation = parser.parse(argv)
|
|
24
|
+
@context = Context.new(shell: @shell, command: invocation.command, options: invocation.options,
|
|
25
|
+
args: invocation.args, argv: invocation.argv, given: invocation.given)
|
|
26
|
+
return help(invocation.command) if invocation.help
|
|
27
|
+
|
|
28
|
+
run_hooks(:after_parse)
|
|
29
|
+
parser.finish(invocation)
|
|
30
|
+
return help(invocation.command) unless invocation.command.action
|
|
31
|
+
|
|
32
|
+
run_hooks(:before_run)
|
|
33
|
+
@context.call(invocation.command.action)
|
|
34
|
+
run_hooks(:after_run)
|
|
35
|
+
0
|
|
36
|
+
rescue Halt => e
|
|
37
|
+
e.code
|
|
38
|
+
rescue Interrupt
|
|
39
|
+
@shell.err_puts
|
|
40
|
+
130
|
|
41
|
+
rescue Errno::EPIPE
|
|
42
|
+
0
|
|
43
|
+
rescue StandardError => e
|
|
44
|
+
report(e)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def help(command)
|
|
50
|
+
@shell.print(Help.new(command, @shell).to_s)
|
|
51
|
+
0
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def run_hooks(kind) = Extensions.hooks(kind).each { |hook| @context.call(hook.block) }
|
|
55
|
+
|
|
56
|
+
def report(error)
|
|
57
|
+
context = @context || Context.new(shell: @shell, command: error.respond_to?(:command) && error.command || @program)
|
|
58
|
+
Extensions.hooks(:on_error).each do |hook|
|
|
59
|
+
next unless hook.match?(error)
|
|
60
|
+
|
|
61
|
+
code = context.call(hook.block, error)
|
|
62
|
+
return code if code.is_a?(Integer)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
case error
|
|
66
|
+
when UsageError
|
|
67
|
+
@shell.err_puts("#{@shell.symbol(:error)} #{error.message}")
|
|
68
|
+
command = error.command || context.command
|
|
69
|
+
@shell.err_puts(@shell.paint("Run '#{command.full_name} --help' for usage.", :muted))
|
|
70
|
+
when Abort
|
|
71
|
+
@shell.err_puts("#{@shell.symbol(:error)} #{error.message}") unless error.silent?
|
|
72
|
+
else
|
|
73
|
+
@shell.err_puts("#{@shell.symbol(:error)} #{error.message}")
|
|
74
|
+
trace(error)
|
|
75
|
+
end
|
|
76
|
+
error.is_a?(Error) ? error.code : 1
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def trace(error)
|
|
80
|
+
return if error.is_a?(Error) || @shell.env["R2UI_TRACE"].to_s.empty?
|
|
81
|
+
|
|
82
|
+
@shell.err_puts(@shell.paint(["#{error.class}: #{error.message}", *error.backtrace].join("\n "), :muted))
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
|
|
5
|
+
module R2UI
|
|
6
|
+
module CLI
|
|
7
|
+
# Where a tool talks to the user, and what that place can do. Every helper writes through a
|
|
8
|
+
# Shell, never to $stdout directly, so the same code is gorgeous on a terminal and plain in a
|
|
9
|
+
# pipe, and tests run it on StringIOs.
|
|
10
|
+
#
|
|
11
|
+
# tty? stdout is a terminal (or `tty:` says so)
|
|
12
|
+
# live? redraw in place: tty? and TERM isn't "dumb"
|
|
13
|
+
# interactive? prompts may take keys: stdin and stdout are terminals and TERM isn't
|
|
14
|
+
# "dumb" (or `interactive:`)
|
|
15
|
+
# color? style output: NO_COLOR unset, and FORCE_COLOR set or tty? (or `color:`)
|
|
16
|
+
#
|
|
17
|
+
# Off a terminal, helpers print one stable line per event (no spinners, no cursor movement,
|
|
18
|
+
# no colour) and prompts fall back to reading a line (docs/cli.md, "On and off a terminal").
|
|
19
|
+
class Shell
|
|
20
|
+
attr_reader :input, :output, :error, :env, :theme
|
|
21
|
+
attr_writer :color
|
|
22
|
+
|
|
23
|
+
def initialize(input: $stdin, output: $stdout, error: $stderr, env: ENV, tty: nil,
|
|
24
|
+
interactive: nil, color: nil, width: nil, theme: Theme.new)
|
|
25
|
+
@input = input
|
|
26
|
+
@output = output
|
|
27
|
+
@error = error
|
|
28
|
+
@env = env
|
|
29
|
+
@tty = tty
|
|
30
|
+
@interactive = interactive
|
|
31
|
+
@color = color
|
|
32
|
+
@width = width
|
|
33
|
+
@theme = theme
|
|
34
|
+
@live = nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def tty? = @tty.nil? ? io_tty?(output) : @tty
|
|
38
|
+
|
|
39
|
+
def live? = tty? && env["TERM"] != "dumb"
|
|
40
|
+
|
|
41
|
+
def interactive?
|
|
42
|
+
return @interactive unless @interactive.nil?
|
|
43
|
+
|
|
44
|
+
tty? && input_tty? && env["TERM"] != "dumb"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Stdin is a terminal. With stdout piped (`tool | tee log`) prompts ask a line on stderr.
|
|
48
|
+
def input_tty?
|
|
49
|
+
return @interactive unless @interactive.nil?
|
|
50
|
+
return @tty unless @tty.nil?
|
|
51
|
+
|
|
52
|
+
io_tty?(input)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def color?
|
|
56
|
+
return @color unless @color.nil?
|
|
57
|
+
return false if present?(env["NO_COLOR"])
|
|
58
|
+
return true if present?(env["FORCE_COLOR"]) && env["FORCE_COLOR"] != "0"
|
|
59
|
+
|
|
60
|
+
live?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def width
|
|
64
|
+
@width || winsize&.last || positive(env["COLUMNS"]) || 80
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def height = winsize&.first || positive(env["LINES"]) || 24
|
|
68
|
+
|
|
69
|
+
# Styles `text` with theme styles (:success, :error, :warn, :info, :accent, :muted, :heading,
|
|
70
|
+
# ...) when color? is on; plain text otherwise. Line by line, so lines keep their widths.
|
|
71
|
+
def paint(text, *styles)
|
|
72
|
+
text = text.to_s
|
|
73
|
+
return text if styles.empty? || !color?
|
|
74
|
+
|
|
75
|
+
text.split("\n", -1).map { |line| line.empty? ? line : theme.render(line, styles) }.join("\n")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# A theme symbol (✔ ✖ ⚠ ℹ ○ –), painted in its own style.
|
|
79
|
+
def symbol(name) = paint(theme.symbol(name), name)
|
|
80
|
+
|
|
81
|
+
# Writes a line to stdout. While a Live region is drawing, the line goes above it.
|
|
82
|
+
def puts(text = "")
|
|
83
|
+
text = text.to_s
|
|
84
|
+
if @live
|
|
85
|
+
@live.println(text)
|
|
86
|
+
else
|
|
87
|
+
write(output, text.end_with?("\n") ? text : "#{text}\n")
|
|
88
|
+
end
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def print(text) = write(output, text.to_s)
|
|
93
|
+
|
|
94
|
+
# Writes a line to stderr (warnings, errors, prompts off a terminal).
|
|
95
|
+
def err_puts(text = "")
|
|
96
|
+
text = text.to_s
|
|
97
|
+
if @live && error_shares_terminal?
|
|
98
|
+
@live.println(text)
|
|
99
|
+
else
|
|
100
|
+
write(error, text.end_with?("\n") ? text : "#{text}\n")
|
|
101
|
+
end
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The Live region drawing now (set by Live; nil otherwise).
|
|
106
|
+
attr_accessor :live
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def write(io, text)
|
|
111
|
+
io.write(text)
|
|
112
|
+
io.flush if io.respond_to?(:flush)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def error_shares_terminal? = io_tty?(error) && tty?
|
|
116
|
+
|
|
117
|
+
def io_tty?(io) = io.respond_to?(:tty?) && io.tty?
|
|
118
|
+
|
|
119
|
+
def winsize
|
|
120
|
+
return nil unless @tty.nil? && output.respond_to?(:winsize) && io_tty?(output)
|
|
121
|
+
|
|
122
|
+
rows, cols = output.winsize
|
|
123
|
+
cols.positive? ? [rows, cols] : nil
|
|
124
|
+
rescue SystemCallError, IOError
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def positive(value)
|
|
129
|
+
n = value.to_i
|
|
130
|
+
n.positive? ? n : nil
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def present?(value) = !value.nil? && !value.empty?
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Named styles as lipgloss options (the same vocabulary as the dashboard DSL's `theme`), and
|
|
137
|
+
# the status symbols. Colours are ANSI palette numbers, so they follow the user's terminal
|
|
138
|
+
# theme, the way npm's and pnpm's do.
|
|
139
|
+
class Theme
|
|
140
|
+
STYLES = {
|
|
141
|
+
success: { foreground: "2" },
|
|
142
|
+
error: { foreground: "1" },
|
|
143
|
+
warn: { foreground: "3" },
|
|
144
|
+
info: { foreground: "4" },
|
|
145
|
+
accent: { foreground: "6" },
|
|
146
|
+
highlight: { foreground: "5", bold: true },
|
|
147
|
+
muted: { faint: true },
|
|
148
|
+
heading: { bold: true },
|
|
149
|
+
pending: { faint: true },
|
|
150
|
+
skipped: { faint: true },
|
|
151
|
+
running: { foreground: "6" }
|
|
152
|
+
}.freeze
|
|
153
|
+
|
|
154
|
+
SYMBOLS = {
|
|
155
|
+
success: "✔",
|
|
156
|
+
error: "✖",
|
|
157
|
+
warn: "⚠",
|
|
158
|
+
info: "ℹ",
|
|
159
|
+
pending: "○",
|
|
160
|
+
skipped: "–",
|
|
161
|
+
pointer: "›",
|
|
162
|
+
bullet: "•"
|
|
163
|
+
}.freeze
|
|
164
|
+
|
|
165
|
+
def initialize(styles: {}, symbols: {})
|
|
166
|
+
@styles = STYLES.merge(styles)
|
|
167
|
+
@symbols = SYMBOLS.merge(symbols)
|
|
168
|
+
@cache = {}
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def symbol(name) = @symbols.fetch(name) { raise ArgumentError, "unknown symbol #{name.inspect}" }
|
|
172
|
+
|
|
173
|
+
def options(name) = @styles.fetch(name) { raise ArgumentError, "unknown style #{name.inspect}" }
|
|
174
|
+
|
|
175
|
+
# A Lipgloss::Style for the named styles merged left to right.
|
|
176
|
+
def style(*names)
|
|
177
|
+
@cache[names] ||= names.map { |n| options(n) }.reduce({}, :merge).reduce(Lipgloss::Style.new) do |style, (key, value)|
|
|
178
|
+
style.public_send(key, value)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def render(text, names) = style(*names).render(text)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
require_relative "../cli"
|
|
5
|
+
|
|
6
|
+
module R2UI
|
|
7
|
+
module CLI
|
|
8
|
+
# Test a tool without a terminal (any test framework):
|
|
9
|
+
#
|
|
10
|
+
# require "r2ui/cli/testing"
|
|
11
|
+
# include R2UI::CLI::Testing
|
|
12
|
+
#
|
|
13
|
+
# result = run_cli(MyTool, "deploy", "api", "--env", "production", input: "y\n")
|
|
14
|
+
# assert_equal 0, result.code
|
|
15
|
+
# assert_includes result.out, "Deployed"
|
|
16
|
+
#
|
|
17
|
+
# By default the shell is a pipe: no colour, no live redraw, prompts read `input` line by
|
|
18
|
+
# line, exactly what CI sees. `tty: true` makes it look like a terminal (live regions draw
|
|
19
|
+
# their escape codes into `out`; prompts still read `input`, as lines, unless `interactive:`).
|
|
20
|
+
module Testing
|
|
21
|
+
Result = Data.define(:code, :out, :err) do
|
|
22
|
+
def success? = code.zero?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
def test_shell(input: "", tty: false, interactive: false, color: false, env: {}, width: 80)
|
|
28
|
+
Shell.new(input: StringIO.new(input), output: StringIO.new, error: StringIO.new, env:, tty:,
|
|
29
|
+
interactive:, color:, width:)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def run_cli(program, *argv, **shell_options)
|
|
33
|
+
shell = test_shell(**shell_options)
|
|
34
|
+
code = program.call(argv.flatten.map(&:to_s), shell:)
|
|
35
|
+
Result.new(code:, out: shell.output.string, err: shell.error.string)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Runs a block with R2UI::CLI.shell swapped for a test shell; returns [value, shell].
|
|
39
|
+
def with_shell(**shell_options)
|
|
40
|
+
shell = test_shell(**shell_options)
|
|
41
|
+
previous = CLI.instance_variable_get(:@shell)
|
|
42
|
+
CLI.shell = shell
|
|
43
|
+
[yield(shell), shell]
|
|
44
|
+
ensure
|
|
45
|
+
CLI.shell = previous
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
data/lib/r2ui/cli.rb
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# r2ui for command-line tools: a declarative commands DSL plus inline helpers (tasks, prompts,
|
|
4
|
+
# styled output) that look like npm/pnpm on a terminal and print plain, stable lines in a pipe.
|
|
5
|
+
#
|
|
6
|
+
# require "r2ui/cli"
|
|
7
|
+
#
|
|
8
|
+
# R2UI.cli "deployer" do
|
|
9
|
+
# summary "Ship apps to the fleet"
|
|
10
|
+
# command :deploy do
|
|
11
|
+
# argument :app
|
|
12
|
+
# option :env, default: "staging", in: %w[staging production]
|
|
13
|
+
# run do
|
|
14
|
+
# confirm("Deploy #{args[:app]} to #{options[:env]}?") or abort!("cancelled")
|
|
15
|
+
# tasks do
|
|
16
|
+
# step("Building") { build }
|
|
17
|
+
# step("Uploading") { upload }
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
# end.start
|
|
22
|
+
#
|
|
23
|
+
# docs/cli.md is the design and the story list. Capabilities live one per file in
|
|
24
|
+
# lib/r2ui/cli/ext/ and load automatically, like the dashboard DSL's lib/r2ui/ext/.
|
|
25
|
+
#
|
|
26
|
+
# This loads r2ui's pure-Ruby Bubbletea and Lipgloss by path (as `require "r2ui"` does for
|
|
27
|
+
# Bubbletea), not the dashboard DSL: a CLI starts fast. `require "r2ui"` too for dashboards.
|
|
28
|
+
require_relative "version"
|
|
29
|
+
require_relative "compat/bubbletea"
|
|
30
|
+
require_relative "compat/lipgloss"
|
|
31
|
+
|
|
32
|
+
module R2UI
|
|
33
|
+
module CLI
|
|
34
|
+
# A failure the tool reports to the user: "✖ message" on stderr, exit code `code` (1).
|
|
35
|
+
class Error < StandardError
|
|
36
|
+
def code = 1
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Wrong arguments or options: the message, a "--help" hint, exit code 2.
|
|
40
|
+
class UsageError < Error
|
|
41
|
+
# The command whose usage it was (for the "--help" hint); set by the parser.
|
|
42
|
+
attr_accessor :command
|
|
43
|
+
|
|
44
|
+
def code = 2
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# `abort!(message, code:)`: stop the command with a message (or none) and an exit code.
|
|
48
|
+
class Abort < Error
|
|
49
|
+
attr_reader :code
|
|
50
|
+
|
|
51
|
+
def initialize(message = nil, code: 1)
|
|
52
|
+
super(message)
|
|
53
|
+
@code = code
|
|
54
|
+
@silent = message.nil?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def silent? = @silent
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# `halt(code)`: stop the command quietly. Not a StandardError, so `rescue => e` in a command
|
|
61
|
+
# doesn't swallow it.
|
|
62
|
+
class Halt < Exception # rubocop:disable Lint/InheritException
|
|
63
|
+
attr_reader :code
|
|
64
|
+
|
|
65
|
+
def initialize(code = 0)
|
|
66
|
+
super("halt #{code}")
|
|
67
|
+
@code = code
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
require_relative "cli/shell"
|
|
74
|
+
require_relative "cli/live"
|
|
75
|
+
require_relative "cli/definition"
|
|
76
|
+
require_relative "cli/parser"
|
|
77
|
+
require_relative "cli/help"
|
|
78
|
+
require_relative "cli/helpers"
|
|
79
|
+
require_relative "cli/context"
|
|
80
|
+
require_relative "cli/extension"
|
|
81
|
+
require_relative "cli/prompt"
|
|
82
|
+
require_relative "cli/runner"
|
|
83
|
+
|
|
84
|
+
module R2UI
|
|
85
|
+
module CLI
|
|
86
|
+
extend Helpers
|
|
87
|
+
|
|
88
|
+
class << self
|
|
89
|
+
# Defines a tool: the block runs on a Builder for the root command (see docs/cli.md).
|
|
90
|
+
# Returns the Program; the last one defined is `R2UI::CLI.program`.
|
|
91
|
+
def define(name = File.basename($PROGRAM_NAME), &)
|
|
92
|
+
@program = Program.build(name, &)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def program = @program || raise(Error, "no CLI defined: call R2UI.cli first")
|
|
96
|
+
|
|
97
|
+
# Runs the last defined tool on ARGV and exits with its code.
|
|
98
|
+
def start(argv = ARGV) = program.start(argv)
|
|
99
|
+
|
|
100
|
+
# The process's terminal (stdin, stdout, stderr, ENV). Helpers called outside a command
|
|
101
|
+
# (a plain script that includes R2UI::CLI::Helpers) use it; tests swap it.
|
|
102
|
+
def shell = @shell ||= Shell.new
|
|
103
|
+
|
|
104
|
+
attr_writer :shell
|
|
105
|
+
|
|
106
|
+
def extension(name, &) = Extensions.register(name, &)
|
|
107
|
+
|
|
108
|
+
# Prompt stories host bubbles components (TextInput, List, ...). Bubbles is optional, so
|
|
109
|
+
# call this when a prompt is first used, never at file load. It opts into r2ui/drop_in, so
|
|
110
|
+
# bubbles runs on r2ui's Bubbletea and Lipgloss.
|
|
111
|
+
def require_bubbles!
|
|
112
|
+
return if defined?(::Bubbles)
|
|
113
|
+
|
|
114
|
+
require_relative "drop_in"
|
|
115
|
+
begin
|
|
116
|
+
require "bubbles"
|
|
117
|
+
rescue LoadError
|
|
118
|
+
raise Error, "this needs the bubbles gem: add `gem \"bubbles\"` to your Gemfile"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# `R2UI.cli "name" do ... end`: the commands DSL entry point, next to R2UI.dashboard.
|
|
125
|
+
def self.cli(name = File.basename($PROGRAM_NAME), &) = CLI.define(name, &)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Capabilities: one self-registering file each (docs/cli.md).
|
|
129
|
+
R2UI::CLI::Extensions.load_all
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Marco Roth
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|