thor-interactive 0.1.0.pre.4 → 0.1.0.pre.6
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/README.md +143 -399
- data/docs/assets/thor-interactive-wide.png +0 -0
- data/docs/assets/thor-interactive.png +0 -0
- data/examples/completion_demo.rb +191 -0
- data/examples/tui_demo.rb +94 -0
- data/lib/thor/interactive/command.rb +12 -1
- data/lib/thor/interactive/command_dispatch.rb +410 -0
- data/lib/thor/interactive/shell.rb +41 -328
- data/lib/thor/interactive/tui/output_buffer.rb +87 -0
- data/lib/thor/interactive/tui/ratatui_shell.rb +606 -0
- data/lib/thor/interactive/tui/spinner.rb +107 -0
- data/lib/thor/interactive/tui/status_bar.rb +58 -0
- data/lib/thor/interactive/tui/text_input.rb +218 -0
- data/lib/thor/interactive/tui/theme.rb +158 -0
- data/lib/thor/interactive/tui.rb +14 -0
- data/lib/thor/interactive/version_constant.rb +1 -1
- metadata +15 -3
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Thor
|
|
4
|
+
module Interactive
|
|
5
|
+
module TUI
|
|
6
|
+
# Configurable status bar with left/center/right sections.
|
|
7
|
+
# App authors provide lambdas that receive the Thor instance.
|
|
8
|
+
class StatusBar
|
|
9
|
+
attr_accessor :left, :center, :right
|
|
10
|
+
|
|
11
|
+
def initialize(thor_class, thor_instance, options = {})
|
|
12
|
+
@thor_class = thor_class
|
|
13
|
+
@thor_instance = thor_instance
|
|
14
|
+
|
|
15
|
+
config = options[:status_bar] || {}
|
|
16
|
+
|
|
17
|
+
@left = config[:left] || ->(instance) { " #{instance.class.name}" }
|
|
18
|
+
@center = config[:center] || ->(instance) { "" }
|
|
19
|
+
@right = config[:right] || ->(instance) { " ready " }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def render_text(width, override_center: nil, override_right: nil)
|
|
23
|
+
left_text = evaluate_section(@left)
|
|
24
|
+
center_text = override_center || evaluate_section(@center)
|
|
25
|
+
right_text = override_right || evaluate_section(@right)
|
|
26
|
+
|
|
27
|
+
# Calculate spacing
|
|
28
|
+
if center_text.empty?
|
|
29
|
+
padding = width - left_text.length - right_text.length
|
|
30
|
+
padding = 1 if padding < 1
|
|
31
|
+
left_text + (" " * padding) + right_text
|
|
32
|
+
else
|
|
33
|
+
# Three-section layout
|
|
34
|
+
left_space = width - left_text.length - center_text.length - right_text.length
|
|
35
|
+
left_pad = [left_space / 2, 1].max
|
|
36
|
+
right_pad = [left_space - left_pad, 1].max
|
|
37
|
+
left_text + (" " * left_pad) + center_text + (" " * right_pad) + right_text
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def evaluate_section(section)
|
|
44
|
+
case section
|
|
45
|
+
when Proc, Method
|
|
46
|
+
section.arity == 0 ? section.call.to_s : section.call(@thor_instance).to_s
|
|
47
|
+
when String
|
|
48
|
+
section
|
|
49
|
+
else
|
|
50
|
+
section.to_s
|
|
51
|
+
end
|
|
52
|
+
rescue => e
|
|
53
|
+
"(error)"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Thor
|
|
4
|
+
module Interactive
|
|
5
|
+
module TUI
|
|
6
|
+
# Multi-line text buffer with cursor tracking.
|
|
7
|
+
# Used as the data model for the input area in the TUI shell.
|
|
8
|
+
class TextInput
|
|
9
|
+
attr_reader :cursor_row, :cursor_col
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@lines = [+""]
|
|
13
|
+
@cursor_row = 0
|
|
14
|
+
@cursor_col = 0
|
|
15
|
+
@history = []
|
|
16
|
+
@history_index = nil
|
|
17
|
+
@saved_input = nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def content
|
|
21
|
+
@lines.join("\n")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def empty?
|
|
25
|
+
@lines.length == 1 && @lines[0].empty?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def lines
|
|
29
|
+
@lines.dup
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def line_count
|
|
33
|
+
@lines.length
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def current_line
|
|
37
|
+
@lines[@cursor_row] || ""
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def insert_char(ch)
|
|
41
|
+
@lines[@cursor_row].insert(@cursor_col, ch)
|
|
42
|
+
@cursor_col += ch.length
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def insert_text(text)
|
|
46
|
+
text_lines = text.split("\n", -1)
|
|
47
|
+
if text_lines.length == 1
|
|
48
|
+
insert_char(text_lines[0])
|
|
49
|
+
else
|
|
50
|
+
# Split current line at cursor
|
|
51
|
+
before = @lines[@cursor_row][0...@cursor_col]
|
|
52
|
+
after = @lines[@cursor_row][@cursor_col..]
|
|
53
|
+
|
|
54
|
+
# First fragment joins with text before cursor
|
|
55
|
+
@lines[@cursor_row] = before + text_lines[0]
|
|
56
|
+
|
|
57
|
+
# Middle lines insert after current row
|
|
58
|
+
text_lines[1...-1].each_with_index do |line, i|
|
|
59
|
+
@lines.insert(@cursor_row + 1 + i, line)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Last fragment joins with text after cursor
|
|
63
|
+
last_line = text_lines.last + after.to_s
|
|
64
|
+
@lines.insert(@cursor_row + text_lines.length - 1, last_line)
|
|
65
|
+
|
|
66
|
+
@cursor_row += text_lines.length - 1
|
|
67
|
+
@cursor_col = text_lines.last.length
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def newline
|
|
72
|
+
# Split current line at cursor
|
|
73
|
+
before = @lines[@cursor_row][0...@cursor_col]
|
|
74
|
+
after = @lines[@cursor_row][@cursor_col..]
|
|
75
|
+
|
|
76
|
+
@lines[@cursor_row] = before
|
|
77
|
+
@lines.insert(@cursor_row + 1, after.to_s)
|
|
78
|
+
@cursor_row += 1
|
|
79
|
+
@cursor_col = 0
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def backspace
|
|
83
|
+
if @cursor_col > 0
|
|
84
|
+
@lines[@cursor_row] = @lines[@cursor_row][0...@cursor_col - 1] + @lines[@cursor_row][@cursor_col..]
|
|
85
|
+
@cursor_col -= 1
|
|
86
|
+
elsif @cursor_row > 0
|
|
87
|
+
# Join with previous line
|
|
88
|
+
prev_col = @lines[@cursor_row - 1].length
|
|
89
|
+
@lines[@cursor_row - 1] += @lines[@cursor_row]
|
|
90
|
+
@lines.delete_at(@cursor_row)
|
|
91
|
+
@cursor_row -= 1
|
|
92
|
+
@cursor_col = prev_col
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def delete_char
|
|
97
|
+
if @cursor_col < @lines[@cursor_row].length
|
|
98
|
+
@lines[@cursor_row] = @lines[@cursor_row][0...@cursor_col] + @lines[@cursor_row][@cursor_col + 1..]
|
|
99
|
+
elsif @cursor_row < @lines.length - 1
|
|
100
|
+
# Join with next line
|
|
101
|
+
@lines[@cursor_row] += @lines[@cursor_row + 1]
|
|
102
|
+
@lines.delete_at(@cursor_row + 1)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def move_left
|
|
107
|
+
if @cursor_col > 0
|
|
108
|
+
@cursor_col -= 1
|
|
109
|
+
elsif @cursor_row > 0
|
|
110
|
+
@cursor_row -= 1
|
|
111
|
+
@cursor_col = @lines[@cursor_row].length
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def move_right
|
|
116
|
+
if @cursor_col < @lines[@cursor_row].length
|
|
117
|
+
@cursor_col += 1
|
|
118
|
+
elsif @cursor_row < @lines.length - 1
|
|
119
|
+
@cursor_row += 1
|
|
120
|
+
@cursor_col = 0
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def move_up
|
|
125
|
+
if @cursor_row > 0
|
|
126
|
+
@cursor_row -= 1
|
|
127
|
+
@cursor_col = [@cursor_col, @lines[@cursor_row].length].min
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def move_down
|
|
132
|
+
if @cursor_row < @lines.length - 1
|
|
133
|
+
@cursor_row += 1
|
|
134
|
+
@cursor_col = [@cursor_col, @lines[@cursor_row].length].min
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def move_home
|
|
139
|
+
@cursor_col = 0
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def move_end
|
|
143
|
+
@cursor_col = @lines[@cursor_row].length
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def clear
|
|
147
|
+
@lines = [+""]
|
|
148
|
+
@cursor_row = 0
|
|
149
|
+
@cursor_col = 0
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Submit the current content and add to history.
|
|
153
|
+
# Returns the content and clears the input.
|
|
154
|
+
def submit
|
|
155
|
+
text = content
|
|
156
|
+
add_to_history(text) unless text.strip.empty?
|
|
157
|
+
clear
|
|
158
|
+
@history_index = nil
|
|
159
|
+
@saved_input = nil
|
|
160
|
+
text
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def add_to_history(text)
|
|
164
|
+
# Don't add duplicates of the most recent entry
|
|
165
|
+
@history.push(text) unless @history.last == text
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def history_back
|
|
169
|
+
return false if @history.empty?
|
|
170
|
+
|
|
171
|
+
if @history_index.nil?
|
|
172
|
+
@saved_input = content
|
|
173
|
+
@history_index = @history.length - 1
|
|
174
|
+
elsif @history_index > 0
|
|
175
|
+
@history_index -= 1
|
|
176
|
+
else
|
|
177
|
+
return false
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
load_from_string(@history[@history_index])
|
|
181
|
+
true
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def history_forward
|
|
185
|
+
return false if @history_index.nil?
|
|
186
|
+
|
|
187
|
+
if @history_index < @history.length - 1
|
|
188
|
+
@history_index += 1
|
|
189
|
+
load_from_string(@history[@history_index])
|
|
190
|
+
else
|
|
191
|
+
@history_index = nil
|
|
192
|
+
load_from_string(@saved_input || "")
|
|
193
|
+
@saved_input = nil
|
|
194
|
+
end
|
|
195
|
+
true
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Load history entries from an array of strings
|
|
199
|
+
def load_history(entries)
|
|
200
|
+
@history = entries.dup
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def history_entries
|
|
204
|
+
@history.dup
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
private
|
|
208
|
+
|
|
209
|
+
def load_from_string(str)
|
|
210
|
+
@lines = str.split("\n", -1).map { |s| +s }
|
|
211
|
+
@lines = [+""] if @lines.empty?
|
|
212
|
+
@cursor_row = @lines.length - 1
|
|
213
|
+
@cursor_col = @lines[@cursor_row].length
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Thor
|
|
4
|
+
module Interactive
|
|
5
|
+
module TUI
|
|
6
|
+
# Theming system for the TUI shell.
|
|
7
|
+
# Predefined themes and custom color configuration.
|
|
8
|
+
class Theme
|
|
9
|
+
THEMES = {
|
|
10
|
+
default: {
|
|
11
|
+
output_fg: nil,
|
|
12
|
+
output_border: :dark_gray,
|
|
13
|
+
error_fg: :red,
|
|
14
|
+
command_echo_fg: :dark_gray,
|
|
15
|
+
system_fg: :cyan,
|
|
16
|
+
input_fg: nil,
|
|
17
|
+
input_border: :green,
|
|
18
|
+
input_title_fg: :green,
|
|
19
|
+
cursor_fg: :black,
|
|
20
|
+
cursor_bg: :white,
|
|
21
|
+
status_bar_fg: :white,
|
|
22
|
+
status_bar_bg: :blue,
|
|
23
|
+
completion_fg: :white,
|
|
24
|
+
completion_bg: :dark_gray,
|
|
25
|
+
completion_selected_fg: :black,
|
|
26
|
+
completion_selected_bg: :cyan
|
|
27
|
+
},
|
|
28
|
+
dark: {
|
|
29
|
+
output_fg: :gray,
|
|
30
|
+
output_border: :dark_gray,
|
|
31
|
+
error_fg: :light_red,
|
|
32
|
+
command_echo_fg: :dark_gray,
|
|
33
|
+
system_fg: :light_cyan,
|
|
34
|
+
input_fg: :white,
|
|
35
|
+
input_border: :light_green,
|
|
36
|
+
input_title_fg: :light_green,
|
|
37
|
+
cursor_fg: :black,
|
|
38
|
+
cursor_bg: :light_yellow,
|
|
39
|
+
status_bar_fg: :white,
|
|
40
|
+
status_bar_bg: :dark_gray,
|
|
41
|
+
completion_fg: :white,
|
|
42
|
+
completion_bg: :dark_gray,
|
|
43
|
+
completion_selected_fg: :black,
|
|
44
|
+
completion_selected_bg: :light_cyan
|
|
45
|
+
},
|
|
46
|
+
light: {
|
|
47
|
+
output_fg: :black,
|
|
48
|
+
output_border: :gray,
|
|
49
|
+
error_fg: :red,
|
|
50
|
+
command_echo_fg: :gray,
|
|
51
|
+
system_fg: :blue,
|
|
52
|
+
input_fg: :black,
|
|
53
|
+
input_border: :green,
|
|
54
|
+
input_title_fg: :green,
|
|
55
|
+
cursor_fg: :white,
|
|
56
|
+
cursor_bg: :black,
|
|
57
|
+
status_bar_fg: :white,
|
|
58
|
+
status_bar_bg: :blue,
|
|
59
|
+
completion_fg: :black,
|
|
60
|
+
completion_bg: :gray,
|
|
61
|
+
completion_selected_fg: :white,
|
|
62
|
+
completion_selected_bg: :blue
|
|
63
|
+
},
|
|
64
|
+
minimal: {
|
|
65
|
+
output_fg: nil,
|
|
66
|
+
output_border: :dark_gray,
|
|
67
|
+
error_fg: :red,
|
|
68
|
+
command_echo_fg: :dark_gray,
|
|
69
|
+
system_fg: :yellow,
|
|
70
|
+
input_fg: nil,
|
|
71
|
+
input_border: :dark_gray,
|
|
72
|
+
input_title_fg: :white,
|
|
73
|
+
cursor_fg: :black,
|
|
74
|
+
cursor_bg: :white,
|
|
75
|
+
status_bar_fg: :black,
|
|
76
|
+
status_bar_bg: :white,
|
|
77
|
+
completion_fg: :white,
|
|
78
|
+
completion_bg: :dark_gray,
|
|
79
|
+
completion_selected_fg: :black,
|
|
80
|
+
completion_selected_bg: :white
|
|
81
|
+
}
|
|
82
|
+
}.freeze
|
|
83
|
+
|
|
84
|
+
attr_reader :colors
|
|
85
|
+
|
|
86
|
+
def initialize(theme = :default)
|
|
87
|
+
@colors = if theme.is_a?(Hash)
|
|
88
|
+
THEMES[:default].merge(theme)
|
|
89
|
+
else
|
|
90
|
+
THEMES.fetch(theme, THEMES[:default]).dup
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def [](key)
|
|
95
|
+
@colors[key]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def style(key)
|
|
99
|
+
color = @colors[key]
|
|
100
|
+
color ? RatatuiRuby::Style::Style.new(fg: color) : nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def output_style
|
|
104
|
+
fg = @colors[:output_fg]
|
|
105
|
+
fg ? RatatuiRuby::Style::Style.new(fg: fg) : nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def error_style
|
|
109
|
+
RatatuiRuby::Style::Style.new(fg: @colors[:error_fg])
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def command_echo_style
|
|
113
|
+
RatatuiRuby::Style::Style.new(fg: @colors[:command_echo_fg])
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def system_style
|
|
117
|
+
RatatuiRuby::Style::Style.new(fg: @colors[:system_fg])
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def input_border_style
|
|
121
|
+
RatatuiRuby::Style::Style.new(fg: @colors[:input_border])
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def input_title_style
|
|
125
|
+
RatatuiRuby::Style::Style.new(fg: @colors[:input_title_fg], modifiers: [:bold])
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def cursor_style
|
|
129
|
+
RatatuiRuby::Style::Style.new(fg: @colors[:cursor_fg], bg: @colors[:cursor_bg], modifiers: [:bold])
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def output_border_style
|
|
133
|
+
RatatuiRuby::Style::Style.new(fg: @colors[:output_border])
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def status_bar_style
|
|
137
|
+
RatatuiRuby::Style::Style.new(fg: @colors[:status_bar_fg], bg: @colors[:status_bar_bg])
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def completion_style
|
|
141
|
+
RatatuiRuby::Style::Style.new(fg: @colors[:completion_fg])
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def completion_bg_style
|
|
145
|
+
RatatuiRuby::Style::Style.new(bg: @colors[:completion_bg])
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def completion_selected_style
|
|
149
|
+
RatatuiRuby::Style::Style.new(
|
|
150
|
+
fg: @colors[:completion_selected_fg],
|
|
151
|
+
bg: @colors[:completion_selected_bg],
|
|
152
|
+
modifiers: [:bold]
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: thor-interactive
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.0.pre.
|
|
4
|
+
version: 0.1.0.pre.6
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Chris Petersen
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2026-03-26 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: thor
|
|
@@ -53,7 +53,10 @@ files:
|
|
|
53
53
|
- LICENSE.txt
|
|
54
54
|
- README.md
|
|
55
55
|
- Rakefile
|
|
56
|
+
- docs/assets/thor-interactive-wide.png
|
|
57
|
+
- docs/assets/thor-interactive.png
|
|
56
58
|
- examples/README.md
|
|
59
|
+
- examples/completion_demo.rb
|
|
57
60
|
- examples/demo_session.rb
|
|
58
61
|
- examples/edge_case_test.rb
|
|
59
62
|
- examples/nested_example.rb
|
|
@@ -61,9 +64,18 @@ files:
|
|
|
61
64
|
- examples/sample_app.rb
|
|
62
65
|
- examples/signal_demo.rb
|
|
63
66
|
- examples/test_interactive.rb
|
|
67
|
+
- examples/tui_demo.rb
|
|
64
68
|
- lib/thor/interactive.rb
|
|
65
69
|
- lib/thor/interactive/command.rb
|
|
70
|
+
- lib/thor/interactive/command_dispatch.rb
|
|
66
71
|
- lib/thor/interactive/shell.rb
|
|
72
|
+
- lib/thor/interactive/tui.rb
|
|
73
|
+
- lib/thor/interactive/tui/output_buffer.rb
|
|
74
|
+
- lib/thor/interactive/tui/ratatui_shell.rb
|
|
75
|
+
- lib/thor/interactive/tui/spinner.rb
|
|
76
|
+
- lib/thor/interactive/tui/status_bar.rb
|
|
77
|
+
- lib/thor/interactive/tui/text_input.rb
|
|
78
|
+
- lib/thor/interactive/tui/theme.rb
|
|
67
79
|
- lib/thor/interactive/version.rb
|
|
68
80
|
- lib/thor/interactive/version_constant.rb
|
|
69
81
|
- sig/thor/interactive.rbs
|
|
@@ -90,7 +102,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
90
102
|
- !ruby/object:Gem::Version
|
|
91
103
|
version: '0'
|
|
92
104
|
requirements: []
|
|
93
|
-
rubygems_version: 3.5.
|
|
105
|
+
rubygems_version: 3.5.22
|
|
94
106
|
signing_key:
|
|
95
107
|
specification_version: 4
|
|
96
108
|
summary: Turn any Thor CLI into an interactive REPL with persistent state and auto-completion
|