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.
Files changed (133) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -0
  3. data/README.md +79 -0
  4. data/lib/r2ui/app.rb +311 -34
  5. data/lib/r2ui/canvas.rb +135 -14
  6. data/lib/r2ui/cli/builder.rb +72 -0
  7. data/lib/r2ui/cli/context.rb +45 -0
  8. data/lib/r2ui/cli/definition.rb +174 -0
  9. data/lib/r2ui/cli/ext/ask.rb +133 -0
  10. data/lib/r2ui/cli/ext/box.rb +112 -0
  11. data/lib/r2ui/cli/ext/choose.rb +162 -0
  12. data/lib/r2ui/cli/ext/choose_many.rb +241 -0
  13. data/lib/r2ui/cli/ext/color_option.rb +64 -0
  14. data/lib/r2ui/cli/ext/completion.rb +520 -0
  15. data/lib/r2ui/cli/ext/config.rb +179 -0
  16. data/lib/r2ui/cli/ext/confirm.rb +110 -0
  17. data/lib/r2ui/cli/ext/dashboard.rb +105 -0
  18. data/lib/r2ui/cli/ext/diff.rb +195 -0
  19. data/lib/r2ui/cli/ext/editor.rb +94 -0
  20. data/lib/r2ui/cli/ext/env.rb +108 -0
  21. data/lib/r2ui/cli/ext/examples.rb +62 -0
  22. data/lib/r2ui/cli/ext/filter.rb +269 -0
  23. data/lib/r2ui/cli/ext/format.rb +104 -0
  24. data/lib/r2ui/cli/ext/list.rb +117 -0
  25. data/lib/r2ui/cli/ext/log.rb +89 -0
  26. data/lib/r2ui/cli/ext/pager.rb +97 -0
  27. data/lib/r2ui/cli/ext/pairs.rb +62 -0
  28. data/lib/r2ui/cli/ext/parallel.rb +274 -0
  29. data/lib/r2ui/cli/ext/password.rb +158 -0
  30. data/lib/r2ui/cli/ext/progress.rb +330 -0
  31. data/lib/r2ui/cli/ext/reference.rb +129 -0
  32. data/lib/r2ui/cli/ext/spinner.rb +165 -0
  33. data/lib/r2ui/cli/ext/summary.rb +63 -0
  34. data/lib/r2ui/cli/ext/table.rb +141 -0
  35. data/lib/r2ui/cli/ext/tasks.rb +216 -0
  36. data/lib/r2ui/cli/ext/trace.rb +64 -0
  37. data/lib/r2ui/cli/ext/tree.rb +114 -0
  38. data/lib/r2ui/cli/ext/version.rb +67 -0
  39. data/lib/r2ui/cli/extension.rb +129 -0
  40. data/lib/r2ui/cli/help.rb +115 -0
  41. data/lib/r2ui/cli/helpers.rb +20 -0
  42. data/lib/r2ui/cli/live.rb +150 -0
  43. data/lib/r2ui/cli/parser.rb +165 -0
  44. data/lib/r2ui/cli/prompt.rb +133 -0
  45. data/lib/r2ui/cli/runner.rb +86 -0
  46. data/lib/r2ui/cli/shell.rb +185 -0
  47. data/lib/r2ui/cli/testing.rb +49 -0
  48. data/lib/r2ui/cli.rb +129 -0
  49. data/lib/r2ui/compat/bubbletea/LICENSE-bubbletea.txt +21 -0
  50. data/lib/r2ui/compat/bubbletea/ansi.rb +424 -0
  51. data/lib/r2ui/compat/bubbletea/commands.rb +143 -0
  52. data/lib/r2ui/compat/bubbletea/input.rb +269 -0
  53. data/lib/r2ui/compat/bubbletea/input_reader.rb +66 -0
  54. data/lib/r2ui/compat/bubbletea/messages.rb +251 -0
  55. data/lib/r2ui/compat/bubbletea/model.rb +65 -0
  56. data/lib/r2ui/compat/bubbletea/native.rb +254 -0
  57. data/lib/r2ui/compat/bubbletea/renderer.rb +192 -0
  58. data/lib/r2ui/compat/bubbletea/runner.rb +425 -0
  59. data/lib/r2ui/compat/bubbletea/terminal.rb +230 -0
  60. data/lib/r2ui/compat/bubbletea/version.rb +8 -0
  61. data/lib/r2ui/compat/bubbletea/width_table.rb +228 -0
  62. data/lib/r2ui/compat/bubbletea.rb +16 -0
  63. data/lib/r2ui/compat/lipgloss/border.rb +17 -0
  64. data/lib/r2ui/compat/lipgloss/color.rb +78 -0
  65. data/lib/r2ui/compat/lipgloss/color_blend.rb +166 -0
  66. data/lib/r2ui/compat/lipgloss/colorful.rb +585 -0
  67. data/lib/r2ui/compat/lipgloss/layout.rb +461 -0
  68. data/lib/r2ui/compat/lipgloss/list.rb +85 -0
  69. data/lib/r2ui/compat/lipgloss/position.rb +30 -0
  70. data/lib/r2ui/compat/lipgloss/style/border.rb +134 -0
  71. data/lib/r2ui/compat/lipgloss/style.rb +1062 -0
  72. data/lib/r2ui/compat/lipgloss/table/render.rb +518 -0
  73. data/lib/r2ui/compat/lipgloss/table.rb +185 -0
  74. data/lib/r2ui/compat/lipgloss/termenv.rb +768 -0
  75. data/lib/r2ui/compat/lipgloss/text/unicode_tables.rb +428 -0
  76. data/lib/r2ui/compat/lipgloss/text.rb +1555 -0
  77. data/lib/r2ui/compat/lipgloss/tree/go_tree.rb +243 -0
  78. data/lib/r2ui/compat/lipgloss/tree.rb +105 -0
  79. data/lib/r2ui/compat/lipgloss/version.rb +15 -0
  80. data/lib/r2ui/compat/lipgloss.rb +64 -0
  81. data/lib/r2ui/compat/load_path/bubbletea.rb +4 -0
  82. data/lib/r2ui/compat/load_path/lipgloss.rb +4 -0
  83. data/lib/r2ui/component.rb +67 -0
  84. data/lib/r2ui/context.rb +87 -0
  85. data/lib/r2ui/drop_in.rb +16 -0
  86. data/lib/r2ui/dsl/column.rb +11 -3
  87. data/lib/r2ui/dsl/dashboard.rb +45 -9
  88. data/lib/r2ui/dsl/resource.rb +12 -0
  89. data/lib/r2ui/ext/after.rb +54 -0
  90. data/lib/r2ui/ext/async.rb +81 -0
  91. data/lib/r2ui/ext/batch_sequence.rb +54 -0
  92. data/lib/r2ui/ext/countdown.rb +52 -0
  93. data/lib/r2ui/ext/data_table.rb +71 -0
  94. data/lib/r2ui/ext/every.rb +66 -0
  95. data/lib/r2ui/ext/exec.rb +116 -0
  96. data/lib/r2ui/ext/file_picker.rb +78 -0
  97. data/lib/r2ui/ext/focus_report.rb +69 -0
  98. data/lib/r2ui/ext/help.rb +62 -0
  99. data/lib/r2ui/ext/keys.rb +64 -0
  100. data/lib/r2ui/ext/list.rb +145 -0
  101. data/lib/r2ui/ext/messages.rb +87 -0
  102. data/lib/r2ui/ext/mouse.rb +134 -0
  103. data/lib/r2ui/ext/paginator.rb +86 -0
  104. data/lib/r2ui/ext/paste.rb +86 -0
  105. data/lib/r2ui/ext/println.rb +21 -0
  106. data/lib/r2ui/ext/progress.rb +85 -0
  107. data/lib/r2ui/ext/refresh.rb +56 -0
  108. data/lib/r2ui/ext/resize.rb +62 -0
  109. data/lib/r2ui/ext/screen.rb +47 -0
  110. data/lib/r2ui/ext/screen_mode.rb +63 -0
  111. data/lib/r2ui/ext/spinner.rb +59 -0
  112. data/lib/r2ui/ext/state.rb +45 -0
  113. data/lib/r2ui/ext/stopwatch.rb +67 -0
  114. data/lib/r2ui/ext/suspend.rb +56 -0
  115. data/lib/r2ui/ext/text_area.rb +73 -0
  116. data/lib/r2ui/ext/text_input.rb +83 -0
  117. data/lib/r2ui/ext/theme.rb +112 -0
  118. data/lib/r2ui/ext/view.rb +37 -0
  119. data/lib/r2ui/ext/viewport.rb +92 -0
  120. data/lib/r2ui/ext/window_title.rb +51 -0
  121. data/lib/r2ui/extension.rb +199 -0
  122. data/lib/r2ui/history.rb +4 -1
  123. data/lib/r2ui/keys.rb +35 -8
  124. data/lib/r2ui/motion.rb +169 -0
  125. data/lib/r2ui/panel_state.rb +46 -2
  126. data/lib/r2ui/renderer.rb +87 -22
  127. data/lib/r2ui/version.rb +1 -1
  128. data/lib/r2ui/widgets/box.rb +6 -3
  129. data/lib/r2ui/widgets/glyphs.rb +149 -0
  130. data/lib/r2ui/widgets/table.rb +109 -25
  131. data/lib/r2ui.rb +12 -1
  132. metadata +119 -4
  133. 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.