thor-interactive 0.1.0.pre.5 → 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/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 -517
- 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 +14 -3
|
@@ -1,54 +1,54 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "reline"
|
|
4
|
-
|
|
5
|
-
require "thor"
|
|
4
|
+
require_relative "command_dispatch"
|
|
6
5
|
|
|
7
6
|
class Thor
|
|
8
7
|
module Interactive
|
|
9
8
|
class Shell
|
|
10
9
|
DEFAULT_PROMPT = "> "
|
|
11
10
|
DEFAULT_HISTORY_FILE = "~/.thor_interactive_history"
|
|
12
|
-
EXIT_COMMANDS = %w[exit quit q].freeze
|
|
13
11
|
|
|
14
12
|
attr_reader :thor_class, :thor_instance, :prompt
|
|
15
13
|
|
|
14
|
+
include CommandDispatch
|
|
15
|
+
|
|
16
16
|
def initialize(thor_class, options = {})
|
|
17
17
|
@thor_class = thor_class
|
|
18
18
|
@thor_instance = thor_class.new
|
|
19
|
-
|
|
19
|
+
|
|
20
20
|
# Merge class-level interactive options if available
|
|
21
21
|
merged_options = {}
|
|
22
22
|
if thor_class.respond_to?(:interactive_options)
|
|
23
23
|
merged_options.merge!(thor_class.interactive_options)
|
|
24
24
|
end
|
|
25
25
|
merged_options.merge!(options)
|
|
26
|
-
|
|
26
|
+
|
|
27
27
|
@merged_options = merged_options
|
|
28
28
|
@default_handler = merged_options[:default_handler]
|
|
29
29
|
@prompt = merged_options[:prompt] || DEFAULT_PROMPT
|
|
30
30
|
@history_file = File.expand_path(merged_options[:history_file] || DEFAULT_HISTORY_FILE)
|
|
31
|
-
|
|
31
|
+
|
|
32
32
|
# Ctrl-C handling configuration
|
|
33
33
|
@ctrl_c_behavior = merged_options[:ctrl_c_behavior] || :clear_prompt
|
|
34
|
-
@double_ctrl_c_timeout = merged_options.key?(:double_ctrl_c_timeout) ?
|
|
34
|
+
@double_ctrl_c_timeout = merged_options.key?(:double_ctrl_c_timeout) ?
|
|
35
35
|
merged_options[:double_ctrl_c_timeout] : 0.5
|
|
36
36
|
@last_interrupt_time = nil
|
|
37
|
-
|
|
37
|
+
|
|
38
38
|
setup_completion
|
|
39
39
|
load_history
|
|
40
40
|
end
|
|
41
41
|
|
|
42
42
|
def start
|
|
43
43
|
# Track that we're in an interactive session
|
|
44
|
-
was_in_session = ENV[
|
|
45
|
-
nesting_level = ENV[
|
|
46
|
-
|
|
47
|
-
ENV[
|
|
48
|
-
ENV[
|
|
49
|
-
|
|
44
|
+
was_in_session = ENV["THOR_INTERACTIVE_SESSION"]
|
|
45
|
+
nesting_level = ENV["THOR_INTERACTIVE_LEVEL"].to_i
|
|
46
|
+
|
|
47
|
+
ENV["THOR_INTERACTIVE_SESSION"] = "true"
|
|
48
|
+
ENV["THOR_INTERACTIVE_LEVEL"] = (nesting_level + 1).to_s
|
|
49
|
+
|
|
50
50
|
puts "(Debug: Interactive session started, level #{nesting_level + 1})" if ENV["DEBUG"]
|
|
51
|
-
|
|
51
|
+
|
|
52
52
|
# Adjust prompt for nested sessions if configured
|
|
53
53
|
display_prompt = @prompt
|
|
54
54
|
if nesting_level > 0 && @merged_options[:nested_prompt_format]
|
|
@@ -56,37 +56,35 @@ class Thor
|
|
|
56
56
|
elsif nesting_level > 0
|
|
57
57
|
display_prompt = "(#{nesting_level + 1}) #{@prompt}"
|
|
58
58
|
end
|
|
59
|
-
|
|
59
|
+
|
|
60
60
|
show_welcome(nesting_level)
|
|
61
|
-
|
|
61
|
+
|
|
62
62
|
puts "(Debug: Entering main loop)" if ENV["DEBUG"]
|
|
63
|
-
|
|
63
|
+
|
|
64
64
|
loop do
|
|
65
65
|
begin
|
|
66
66
|
line = Reline.readline(display_prompt, true)
|
|
67
67
|
puts "(Debug: Got input: #{line.inspect})" if ENV["DEBUG"]
|
|
68
|
-
|
|
68
|
+
|
|
69
69
|
# Reset interrupt tracking on successful input
|
|
70
70
|
@last_interrupt_time = nil if line
|
|
71
|
-
|
|
71
|
+
|
|
72
72
|
if should_exit?(line)
|
|
73
73
|
puts "(Debug: Exit condition met)" if ENV["DEBUG"]
|
|
74
74
|
break
|
|
75
75
|
end
|
|
76
|
-
|
|
76
|
+
|
|
77
77
|
next if line.nil? || line.strip.empty?
|
|
78
|
-
|
|
78
|
+
|
|
79
79
|
puts "(Debug: Processing input: #{line.strip})" if ENV["DEBUG"]
|
|
80
80
|
process_input(line.strip)
|
|
81
81
|
puts "(Debug: Input processed successfully)" if ENV["DEBUG"]
|
|
82
|
-
|
|
83
82
|
rescue Interrupt
|
|
84
83
|
# Handle Ctrl-C
|
|
85
84
|
if handle_interrupt
|
|
86
|
-
break
|
|
85
|
+
break # Exit on double Ctrl-C
|
|
87
86
|
end
|
|
88
|
-
next
|
|
89
|
-
|
|
87
|
+
next # Continue on single Ctrl-C
|
|
90
88
|
rescue SystemExit => e
|
|
91
89
|
puts "A command tried to exit with code #{e.status}. Staying in interactive mode."
|
|
92
90
|
puts "(Debug: SystemExit caught in main loop)" if ENV["DEBUG"]
|
|
@@ -97,19 +95,18 @@ class Thor
|
|
|
97
95
|
# Continue the loop - don't let errors break the session
|
|
98
96
|
end
|
|
99
97
|
end
|
|
100
|
-
|
|
98
|
+
|
|
101
99
|
puts "(Debug: Exited main loop)" if ENV["DEBUG"]
|
|
102
100
|
save_history
|
|
103
101
|
puts nesting_level > 0 ? "Exiting nested session..." : "Goodbye!"
|
|
104
|
-
|
|
105
102
|
ensure
|
|
106
103
|
# Restore previous session state
|
|
107
104
|
if was_in_session
|
|
108
|
-
ENV[
|
|
109
|
-
ENV[
|
|
105
|
+
ENV["THOR_INTERACTIVE_SESSION"] = "true"
|
|
106
|
+
ENV["THOR_INTERACTIVE_LEVEL"] = nesting_level.to_s
|
|
110
107
|
else
|
|
111
|
-
ENV.delete(
|
|
112
|
-
ENV.delete(
|
|
108
|
+
ENV.delete("THOR_INTERACTIVE_SESSION")
|
|
109
|
+
ENV.delete("THOR_INTERACTIVE_LEVEL")
|
|
113
110
|
end
|
|
114
111
|
end
|
|
115
112
|
|
|
@@ -121,491 +118,18 @@ class Thor
|
|
|
121
118
|
end
|
|
122
119
|
end
|
|
123
120
|
|
|
124
|
-
def complete_input(text, preposing)
|
|
125
|
-
# Handle completion for slash commands
|
|
126
|
-
full_line = preposing + text
|
|
127
|
-
|
|
128
|
-
if full_line.start_with?('/')
|
|
129
|
-
# Command completion mode
|
|
130
|
-
if preposing.strip == '/' || preposing.strip.empty?
|
|
131
|
-
# Complete command names with / prefix
|
|
132
|
-
command_completions = complete_commands(text.sub(/^\//, ''))
|
|
133
|
-
command_completions.map { |cmd| "/#{cmd}" }
|
|
134
|
-
else
|
|
135
|
-
# Complete command arguments (basic implementation)
|
|
136
|
-
complete_command_options(text, preposing)
|
|
137
|
-
end
|
|
138
|
-
else
|
|
139
|
-
# Natural language mode - no completion for now
|
|
140
|
-
[]
|
|
141
|
-
end
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
def complete_commands(text)
|
|
145
|
-
return [] if text.nil?
|
|
146
|
-
|
|
147
|
-
command_names = @thor_class.tasks.keys + EXIT_COMMANDS + ["help"]
|
|
148
|
-
command_names.select { |cmd| cmd.start_with?(text) }.sort
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
def complete_command_options(text, preposing)
|
|
152
|
-
# Parse the command and check what we're completing
|
|
153
|
-
parts = preposing.split(/\s+/)
|
|
154
|
-
command = parts[0].sub(/^\//, '') if parts[0]
|
|
155
|
-
|
|
156
|
-
# Check if this is a subcommand
|
|
157
|
-
subcommand_class = @thor_class.subcommand_classes[command] if command
|
|
158
|
-
if subcommand_class
|
|
159
|
-
return complete_subcommand_args(subcommand_class, text, parts)
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
# Get the Thor task if it exists
|
|
163
|
-
task = @thor_class.tasks[command] if command
|
|
164
|
-
|
|
165
|
-
# Check if we're likely completing a path
|
|
166
|
-
if path_like?(text) || after_path_option?(preposing)
|
|
167
|
-
complete_path(text)
|
|
168
|
-
elsif text.start_with?('--') || text.start_with?('-')
|
|
169
|
-
# Complete option names
|
|
170
|
-
complete_option_names(task, text)
|
|
171
|
-
else
|
|
172
|
-
# Default to path completion for positional args that might be files
|
|
173
|
-
# This helps with commands that take file arguments
|
|
174
|
-
complete_path(text)
|
|
175
|
-
end
|
|
176
|
-
end
|
|
177
|
-
|
|
178
|
-
def complete_subcommand_args(subcommand_class, text, parts)
|
|
179
|
-
# parts[0] = "/db" or "db", parts[1..] = subcommand args
|
|
180
|
-
if parts.length <= 1
|
|
181
|
-
# No subcommand yet typed, complete subcommand names
|
|
182
|
-
# e.g. "/db <TAB>"
|
|
183
|
-
complete_subcommands(subcommand_class, text)
|
|
184
|
-
else
|
|
185
|
-
# A subcommand name has been typed, check for option completion
|
|
186
|
-
sub_cmd_name = parts[1]
|
|
187
|
-
sub_task = subcommand_class.tasks[sub_cmd_name]
|
|
188
|
-
|
|
189
|
-
if text.start_with?('--') || text.start_with?('-')
|
|
190
|
-
complete_option_names(sub_task, text)
|
|
191
|
-
else
|
|
192
|
-
# Could be completing a subcommand name or a positional arg
|
|
193
|
-
if parts.length == 2 && !text.empty?
|
|
194
|
-
# Still typing the subcommand name, e.g. "/db cr<TAB>"
|
|
195
|
-
# But only if 'text' is part of a subcommand name being typed
|
|
196
|
-
# (parts[1] is the preposing word, text is what's being completed)
|
|
197
|
-
complete_subcommands(subcommand_class, text)
|
|
198
|
-
else
|
|
199
|
-
complete_path(text)
|
|
200
|
-
end
|
|
201
|
-
end
|
|
202
|
-
end
|
|
203
|
-
end
|
|
204
|
-
|
|
205
|
-
def complete_subcommands(subcommand_class, text)
|
|
206
|
-
return [] if text.nil?
|
|
207
|
-
|
|
208
|
-
command_names = subcommand_class.tasks.keys
|
|
209
|
-
command_names.select { |cmd| cmd.start_with?(text) }.sort
|
|
210
|
-
end
|
|
211
|
-
|
|
212
|
-
def path_like?(text)
|
|
213
|
-
# Check if text looks like a path
|
|
214
|
-
text.match?(%r{^[~./]|/}) || text.match?(/\.(txt|rb|md|json|xml|yaml|yml|csv|log|html|css|js)$/i)
|
|
215
|
-
end
|
|
216
|
-
|
|
217
|
-
def after_path_option?(preposing)
|
|
218
|
-
# Check if we're after a common file/path option
|
|
219
|
-
preposing.match?(/(?:--file|--output|--input|--path|--dir|--directory|-f|-o|-i|-d)\s*$/)
|
|
220
|
-
end
|
|
221
|
-
|
|
222
|
-
def complete_path(text)
|
|
223
|
-
return [] if text.nil?
|
|
224
|
-
|
|
225
|
-
# Special case for empty text - show files in current directory
|
|
226
|
-
if text.empty?
|
|
227
|
-
matches = Dir.glob("*", File::FNM_DOTMATCH).select do |path|
|
|
228
|
-
basename = File.basename(path)
|
|
229
|
-
basename != '.' && basename != '..'
|
|
230
|
-
end
|
|
231
|
-
return format_path_completions(matches, text)
|
|
232
|
-
end
|
|
233
|
-
|
|
234
|
-
# Expand ~ to home directory for matching
|
|
235
|
-
expanded = text.start_with?('~') ? File.expand_path(text) : text
|
|
236
|
-
|
|
237
|
-
# Determine directory and prefix for matching
|
|
238
|
-
if text.end_with?('/')
|
|
239
|
-
# User typed a directory with trailing slash - show its contents
|
|
240
|
-
dir = expanded
|
|
241
|
-
prefix = ''
|
|
242
|
-
elsif File.directory?(expanded) && !text.end_with?('/')
|
|
243
|
-
# It's a directory without trailing slash - complete the directory name
|
|
244
|
-
dir = File.dirname(expanded)
|
|
245
|
-
prefix = File.basename(expanded)
|
|
246
|
-
else
|
|
247
|
-
# Completing a partial filename
|
|
248
|
-
dir = File.dirname(expanded)
|
|
249
|
-
prefix = File.basename(expanded)
|
|
250
|
-
end
|
|
251
|
-
|
|
252
|
-
# Get matching files/dirs
|
|
253
|
-
pattern = File.join(dir, "#{prefix}*")
|
|
254
|
-
matches = Dir.glob(pattern, File::FNM_DOTMATCH).select do |path|
|
|
255
|
-
# Filter out . and .. entries
|
|
256
|
-
basename = File.basename(path)
|
|
257
|
-
basename != '.' && basename != '..'
|
|
258
|
-
end
|
|
259
|
-
|
|
260
|
-
format_path_completions(matches, text)
|
|
261
|
-
rescue => e
|
|
262
|
-
# If path completion fails, return empty array
|
|
263
|
-
[]
|
|
264
|
-
end
|
|
265
|
-
|
|
266
|
-
def format_path_completions(matches, original_text)
|
|
267
|
-
# Format the completions based on how the user typed the path
|
|
268
|
-
matches.map do |path|
|
|
269
|
-
# Add trailing / for directories
|
|
270
|
-
display_path = File.directory?(path) && !path.end_with?('/') ? "#{path}/" : path
|
|
271
|
-
|
|
272
|
-
# Handle paths with spaces by escaping them
|
|
273
|
-
display_path = display_path.gsub(' ', '\ ')
|
|
274
|
-
|
|
275
|
-
# Return path as user would type it
|
|
276
|
-
if original_text.start_with?('~')
|
|
277
|
-
# Replace home directory with ~
|
|
278
|
-
home = ENV['HOME']
|
|
279
|
-
if display_path.start_with?(home)
|
|
280
|
-
"~#{display_path[home.length..-1]}"
|
|
281
|
-
else
|
|
282
|
-
display_path.sub(ENV['HOME'], '~')
|
|
283
|
-
end
|
|
284
|
-
elsif original_text.start_with?('./')
|
|
285
|
-
# Keep ./ prefix and make path relative
|
|
286
|
-
if display_path.start_with?(Dir.pwd)
|
|
287
|
-
rel_path = display_path.sub(/^#{Regexp.escape(Dir.pwd)}\//, '')
|
|
288
|
-
"./#{rel_path}"
|
|
289
|
-
else
|
|
290
|
-
# Already relative, just ensure ./ prefix
|
|
291
|
-
display_path.start_with?('./') ? display_path : "./#{File.basename(display_path)}"
|
|
292
|
-
end
|
|
293
|
-
elsif original_text.start_with?('/')
|
|
294
|
-
# Absolute path - return as is
|
|
295
|
-
display_path
|
|
296
|
-
else
|
|
297
|
-
# Relative path without ./ prefix
|
|
298
|
-
# If the matched path is in current dir, just return the basename
|
|
299
|
-
dir = File.dirname(display_path)
|
|
300
|
-
if dir == '.' || display_path.start_with?('./')
|
|
301
|
-
basename = File.basename(display_path)
|
|
302
|
-
basename += '/' if File.directory?(display_path.gsub('\ ', ' ')) && !basename.end_with?('/')
|
|
303
|
-
basename
|
|
304
|
-
else
|
|
305
|
-
display_path.sub(/^#{Regexp.escape(Dir.pwd)}\//, '')
|
|
306
|
-
end
|
|
307
|
-
end
|
|
308
|
-
end.sort
|
|
309
|
-
end
|
|
310
|
-
|
|
311
|
-
def complete_option_names(task, text)
|
|
312
|
-
return [] unless task && task.options
|
|
313
|
-
|
|
314
|
-
# Get all option names (long and short forms)
|
|
315
|
-
options = []
|
|
316
|
-
task.options.each do |name, option|
|
|
317
|
-
options << "--#{name}"
|
|
318
|
-
if option.aliases
|
|
319
|
-
# Aliases can be string or array
|
|
320
|
-
aliases = option.aliases.is_a?(Array) ? option.aliases : [option.aliases]
|
|
321
|
-
aliases.each { |a| options << a if a.start_with?('-') }
|
|
322
|
-
end
|
|
323
|
-
end
|
|
324
|
-
|
|
325
|
-
# Filter by what user has typed
|
|
326
|
-
options.select { |opt| opt.start_with?(text) }.sort
|
|
327
|
-
end
|
|
328
|
-
|
|
329
|
-
def process_input(input)
|
|
330
|
-
# Handle completely empty input
|
|
331
|
-
return if input.nil? || input.strip.empty?
|
|
332
|
-
|
|
333
|
-
# Check if input starts with / for explicit command mode
|
|
334
|
-
if input.strip.start_with?('/')
|
|
335
|
-
# Explicit command mode: /command args
|
|
336
|
-
handle_slash_command(input.strip[1..-1])
|
|
337
|
-
elsif is_help_request?(input)
|
|
338
|
-
# Special case: treat bare "help" as /help for convenience
|
|
339
|
-
if input.strip.split.length == 1
|
|
340
|
-
show_help
|
|
341
|
-
else
|
|
342
|
-
command_part = input.strip.split[1]
|
|
343
|
-
show_help(command_part)
|
|
344
|
-
end
|
|
345
|
-
else
|
|
346
|
-
# Determine if this looks like a command or natural language
|
|
347
|
-
command_word = input.strip.split(/\s+/, 2).first
|
|
348
|
-
|
|
349
|
-
if thor_command?(command_word)
|
|
350
|
-
# Looks like a command - handle it as a command (backward compatibility)
|
|
351
|
-
handle_command(input.strip)
|
|
352
|
-
elsif @default_handler
|
|
353
|
-
# Natural language mode: send whole input to default handler
|
|
354
|
-
begin
|
|
355
|
-
@default_handler.call(input, @thor_instance)
|
|
356
|
-
rescue => e
|
|
357
|
-
puts "Error in default handler: #{e.message}"
|
|
358
|
-
puts "Input was: #{input}"
|
|
359
|
-
puts "Try using /commands or type '/help' for available commands."
|
|
360
|
-
end
|
|
361
|
-
else
|
|
362
|
-
# No default handler, suggest using command mode
|
|
363
|
-
puts "No default handler configured. Use /command for commands, or type '/help' for available commands."
|
|
364
|
-
end
|
|
365
|
-
end
|
|
366
|
-
end
|
|
367
|
-
|
|
368
|
-
def handle_slash_command(command_input)
|
|
369
|
-
return if command_input.empty?
|
|
370
|
-
handle_command(command_input)
|
|
371
|
-
end
|
|
372
|
-
|
|
373
|
-
def handle_command(command_input)
|
|
374
|
-
# Extract command and check if it's a single-text command
|
|
375
|
-
command_word = command_input.split(/\s+/, 2).first
|
|
376
|
-
|
|
377
|
-
if thor_command?(command_word)
|
|
378
|
-
task = @thor_class.tasks[command_word]
|
|
379
|
-
|
|
380
|
-
if task && single_text_command?(task) && !task.options.any?
|
|
381
|
-
# Single text command without options - pass everything after command as one argument
|
|
382
|
-
text_part = command_input.sub(/^#{Regexp.escape(command_word)}\s*/, '')
|
|
383
|
-
if text_part.empty?
|
|
384
|
-
invoke_thor_command(command_word, [])
|
|
385
|
-
else
|
|
386
|
-
invoke_thor_command(command_word, [text_part])
|
|
387
|
-
end
|
|
388
|
-
else
|
|
389
|
-
# Multi-argument command, use proper parsing
|
|
390
|
-
args = safe_parse_input(command_input)
|
|
391
|
-
if args && !args.empty?
|
|
392
|
-
command = args.shift
|
|
393
|
-
invoke_thor_command(command, args)
|
|
394
|
-
else
|
|
395
|
-
# Parsing failed, try simple split
|
|
396
|
-
parts = command_input.split(/\s+/)
|
|
397
|
-
command = parts.shift
|
|
398
|
-
invoke_thor_command(command, parts)
|
|
399
|
-
end
|
|
400
|
-
end
|
|
401
|
-
else
|
|
402
|
-
puts "Unknown command: '#{command_word}'. Type '/help' for available commands."
|
|
403
|
-
end
|
|
404
|
-
end
|
|
405
|
-
|
|
406
|
-
def safe_parse_input(input)
|
|
407
|
-
# Try proper shell parsing first
|
|
408
|
-
Shellwords.split(input)
|
|
409
|
-
rescue ArgumentError
|
|
410
|
-
# If parsing fails, return nil so caller can handle it
|
|
411
|
-
nil
|
|
412
|
-
end
|
|
413
|
-
|
|
414
|
-
def parse_input(input)
|
|
415
|
-
# Legacy method - kept for backward compatibility
|
|
416
|
-
safe_parse_input(input) || []
|
|
417
|
-
end
|
|
418
|
-
|
|
419
|
-
def handle_unparseable_command(input, command_word)
|
|
420
|
-
# For commands that failed shell parsing, try intelligent handling
|
|
421
|
-
task = @thor_class.tasks[command_word]
|
|
422
|
-
|
|
423
|
-
# Always try single text approach first for better natural language support
|
|
424
|
-
text_part = input.strip.sub(/^#{Regexp.escape(command_word)}\s*/, '')
|
|
425
|
-
if text_part.empty?
|
|
426
|
-
invoke_thor_command(command_word, [])
|
|
427
|
-
else
|
|
428
|
-
invoke_thor_command(command_word, [text_part])
|
|
429
|
-
end
|
|
430
|
-
end
|
|
431
|
-
|
|
432
|
-
def single_text_command?(task)
|
|
433
|
-
# Heuristic: determine if this is likely a single text command
|
|
434
|
-
return false unless task
|
|
435
|
-
|
|
436
|
-
# Check the method signature to see how many parameters it expects
|
|
437
|
-
method_name = task.name.to_sym
|
|
438
|
-
if @thor_instance.respond_to?(method_name)
|
|
439
|
-
method_obj = @thor_instance.method(method_name)
|
|
440
|
-
param_count = method_obj.parameters.count { |type, _| type == :req }
|
|
441
|
-
|
|
442
|
-
# Only single required parameter = likely text command
|
|
443
|
-
param_count == 1
|
|
444
|
-
else
|
|
445
|
-
# Fallback for introspection issues
|
|
446
|
-
false
|
|
447
|
-
end
|
|
448
|
-
rescue
|
|
449
|
-
# If introspection fails, default to false (safer)
|
|
450
|
-
false
|
|
451
|
-
end
|
|
452
|
-
|
|
453
|
-
def is_help_request?(input)
|
|
454
|
-
# Check if input is a help request (help, ?, etc.)
|
|
455
|
-
stripped = input.strip.downcase
|
|
456
|
-
stripped == "help" || stripped.start_with?("help ")
|
|
457
|
-
end
|
|
458
|
-
|
|
459
|
-
def thor_command?(command)
|
|
460
|
-
@thor_class.tasks.key?(command) ||
|
|
461
|
-
@thor_class.subcommand_classes.key?(command) ||
|
|
462
|
-
command == "help"
|
|
463
|
-
end
|
|
464
|
-
|
|
465
|
-
def invoke_thor_command(command, args)
|
|
466
|
-
# Use the persistent instance to maintain state
|
|
467
|
-
if command == "help"
|
|
468
|
-
show_help(args.first)
|
|
469
|
-
else
|
|
470
|
-
# Get the Thor task/command definition
|
|
471
|
-
task = @thor_class.tasks[command]
|
|
472
|
-
|
|
473
|
-
if task && task.options && !task.options.empty?
|
|
474
|
-
# Parse options if the command has them defined
|
|
475
|
-
result = parse_thor_options(args, task)
|
|
476
|
-
return unless result # Parse failed, error already shown
|
|
477
|
-
|
|
478
|
-
parsed_args, parsed_options = result
|
|
479
|
-
|
|
480
|
-
# Set options on the Thor instance
|
|
481
|
-
@thor_instance.options = Thor::CoreExt::HashWithIndifferentAccess.new(parsed_options)
|
|
482
|
-
|
|
483
|
-
# Call with parsed arguments only (options are in the options hash)
|
|
484
|
-
if @thor_instance.respond_to?(command)
|
|
485
|
-
@thor_instance.send(command, *parsed_args)
|
|
486
|
-
else
|
|
487
|
-
@thor_instance.send(command, *parsed_args)
|
|
488
|
-
end
|
|
489
|
-
else
|
|
490
|
-
# No options defined, use original behavior
|
|
491
|
-
if @thor_instance.respond_to?(command)
|
|
492
|
-
@thor_instance.send(command, *args)
|
|
493
|
-
else
|
|
494
|
-
@thor_instance.send(command, *args)
|
|
495
|
-
end
|
|
496
|
-
end
|
|
497
|
-
end
|
|
498
|
-
rescue SystemExit => e
|
|
499
|
-
if e.status == 0
|
|
500
|
-
puts "Command completed successfully (would have exited with code 0 in CLI mode)"
|
|
501
|
-
else
|
|
502
|
-
puts "Command failed with exit code #{e.status}"
|
|
503
|
-
end
|
|
504
|
-
puts "(Use 'exit' or Ctrl+D to exit the interactive session)" if ENV["DEBUG"]
|
|
505
|
-
rescue Thor::Error => e
|
|
506
|
-
puts "Thor Error: #{e.message}"
|
|
507
|
-
rescue ArgumentError => e
|
|
508
|
-
puts "Thor Error: #{e.message}"
|
|
509
|
-
puts "Try: help #{command}" if thor_command?(command)
|
|
510
|
-
rescue StandardError => e
|
|
511
|
-
puts "Error: #{e.message}"
|
|
512
|
-
puts "Command: #{command}, Args: #{args.inspect}" if ENV["DEBUG"]
|
|
513
|
-
end
|
|
514
|
-
|
|
515
|
-
def parse_thor_options(args, task)
|
|
516
|
-
# Convert args array to a format Thor's option parser expects
|
|
517
|
-
remaining_args = []
|
|
518
|
-
parsed_options = {}
|
|
519
|
-
|
|
520
|
-
begin
|
|
521
|
-
# Create a temporary parser using Thor's options
|
|
522
|
-
parser = Thor::Options.new(task.options)
|
|
523
|
-
|
|
524
|
-
if args.is_a?(Array)
|
|
525
|
-
# Parse the options from the array
|
|
526
|
-
parsed_options = parser.parse(args)
|
|
527
|
-
remaining_args = parser.remaining
|
|
528
|
-
else
|
|
529
|
-
# Single string argument, split it first
|
|
530
|
-
split_args = safe_parse_input(args) || args.split(/\s+/)
|
|
531
|
-
parsed_options = parser.parse(split_args)
|
|
532
|
-
remaining_args = parser.remaining
|
|
533
|
-
end
|
|
534
|
-
rescue Thor::Error => e
|
|
535
|
-
# Show user-friendly error for option parsing failures (e.g. invalid numeric value)
|
|
536
|
-
puts "Option error: #{e.message}"
|
|
537
|
-
return nil
|
|
538
|
-
end
|
|
539
|
-
|
|
540
|
-
# Check for unknown options left in remaining args
|
|
541
|
-
unknown = remaining_args.select { |a| a.start_with?('--') || (a.start_with?('-') && a.length > 1 && !a.match?(/^-\d/)) }
|
|
542
|
-
unless unknown.empty?
|
|
543
|
-
puts "Unknown option#{'s' if unknown.length > 1}: #{unknown.join(', ')}"
|
|
544
|
-
puts "Run '/help #{task.name}' to see available options."
|
|
545
|
-
return nil
|
|
546
|
-
end
|
|
547
|
-
|
|
548
|
-
[remaining_args, parsed_options]
|
|
549
|
-
end
|
|
550
|
-
|
|
551
|
-
def show_help(command = nil)
|
|
552
|
-
if command && @thor_class.subcommand_classes.key?(command)
|
|
553
|
-
# Show help for a subcommand — list its available commands
|
|
554
|
-
subcommand_class = @thor_class.subcommand_classes[command]
|
|
555
|
-
puts "Commands for '#{command}':"
|
|
556
|
-
subcommand_class.tasks.each do |name, task|
|
|
557
|
-
puts " /#{command} #{name.ljust(15)} #{task.description}"
|
|
558
|
-
end
|
|
559
|
-
puts
|
|
560
|
-
elsif command && @thor_class.tasks.key?(command)
|
|
561
|
-
@thor_class.command_help(Thor::Base.shell.new, command)
|
|
562
|
-
else
|
|
563
|
-
puts "Available commands (prefix with /):"
|
|
564
|
-
@thor_class.tasks.each do |name, task|
|
|
565
|
-
puts " /#{name.ljust(19)} #{task.description}"
|
|
566
|
-
end
|
|
567
|
-
puts
|
|
568
|
-
puts "Special commands:"
|
|
569
|
-
puts " /help [COMMAND] Show help for command"
|
|
570
|
-
puts " /exit, /quit, /q Exit the REPL"
|
|
571
|
-
puts
|
|
572
|
-
if @default_handler
|
|
573
|
-
puts "Natural language mode:"
|
|
574
|
-
puts " Type anything without / to use default handler"
|
|
575
|
-
else
|
|
576
|
-
puts "Use /command syntax for all commands"
|
|
577
|
-
end
|
|
578
|
-
puts
|
|
579
|
-
if ENV["DEBUG"]
|
|
580
|
-
puts "Debug info:"
|
|
581
|
-
puts " Thor class: #{@thor_class.name}"
|
|
582
|
-
puts " Available tasks: #{@thor_class.tasks.keys.sort}"
|
|
583
|
-
puts " Instance methods: #{@thor_instance.methods.grep(/^[a-z]/).sort}" if @thor_instance
|
|
584
|
-
puts
|
|
585
|
-
end
|
|
586
|
-
end
|
|
587
|
-
end
|
|
588
|
-
|
|
589
|
-
def should_exit?(line)
|
|
590
|
-
return true if line.nil? # Ctrl+D
|
|
591
|
-
|
|
592
|
-
stripped = line.strip.downcase
|
|
593
|
-
# Handle both /exit and exit for convenience
|
|
594
|
-
EXIT_COMMANDS.include?(stripped) || EXIT_COMMANDS.include?(stripped.sub(/^\//, ''))
|
|
595
|
-
end
|
|
596
|
-
|
|
597
121
|
def handle_interrupt
|
|
598
122
|
current_time = Time.now
|
|
599
|
-
|
|
123
|
+
|
|
600
124
|
# Check for double Ctrl-C
|
|
601
125
|
if @last_interrupt_time && @double_ctrl_c_timeout && (current_time - @last_interrupt_time) < @double_ctrl_c_timeout
|
|
602
126
|
puts "\n(Interrupted twice - exiting)"
|
|
603
|
-
@last_interrupt_time = nil
|
|
604
|
-
return true
|
|
127
|
+
@last_interrupt_time = nil # Reset for next time
|
|
128
|
+
return true # Signal to exit
|
|
605
129
|
end
|
|
606
|
-
|
|
130
|
+
|
|
607
131
|
@last_interrupt_time = current_time
|
|
608
|
-
|
|
132
|
+
|
|
609
133
|
# Single Ctrl-C behavior
|
|
610
134
|
case @ctrl_c_behavior
|
|
611
135
|
when :clear_prompt
|
|
@@ -616,13 +140,13 @@ class Thor
|
|
|
616
140
|
puts "Press Ctrl-C again to exit, or type 'help' for commands"
|
|
617
141
|
when :silent
|
|
618
142
|
# Just clear the line, no message
|
|
619
|
-
print "\r#{
|
|
143
|
+
print "\r#{" " * 80}\r"
|
|
620
144
|
else
|
|
621
145
|
# Default behavior
|
|
622
146
|
puts "^C"
|
|
623
147
|
end
|
|
624
|
-
|
|
625
|
-
false
|
|
148
|
+
|
|
149
|
+
false # Don't exit, just clear prompt
|
|
626
150
|
end
|
|
627
151
|
|
|
628
152
|
def show_welcome(nesting_level = 0)
|
|
@@ -638,7 +162,7 @@ class Thor
|
|
|
638
162
|
|
|
639
163
|
def load_history
|
|
640
164
|
return unless File.exist?(@history_file)
|
|
641
|
-
|
|
165
|
+
|
|
642
166
|
File.readlines(@history_file, chomp: true).each do |line|
|
|
643
167
|
Reline::HISTORY << line
|
|
644
168
|
end
|
|
@@ -648,11 +172,11 @@ class Thor
|
|
|
648
172
|
|
|
649
173
|
def save_history
|
|
650
174
|
return unless Reline::HISTORY.size > 0
|
|
651
|
-
|
|
175
|
+
|
|
652
176
|
File.write(@history_file, Reline::HISTORY.to_a.join("\n"))
|
|
653
177
|
rescue => e
|
|
654
|
-
# Ignore history saving errors
|
|
178
|
+
# Ignore history saving errors
|
|
655
179
|
end
|
|
656
180
|
end
|
|
657
181
|
end
|
|
658
|
-
end
|
|
182
|
+
end
|