thor-interactive 0.1.0.pre.3 → 0.1.0.pre.5
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/examples/completion_demo.rb +191 -0
- data/examples/edge_case_test.rb +107 -0
- data/examples/options_demo.rb +235 -0
- data/lib/thor/interactive/shell.rb +246 -11
- data/lib/thor/interactive/version_constant.rb +1 -1
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7166b4156516b241939081e8c9fc67aba249ceae1be85b4693d4afdb7638b08d
|
|
4
|
+
data.tar.gz: 8c3f92ab064d86f27a028dd685037d8e205e452ae840a674c39025d21fd8acfb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 66f640e0df6d3f1699c6cd1e9612bd6f149da4a7716bcc66e49092a4dbe0f6e568eb98e97dc9e8eecc12548f8149eadbf01ab4149e0ab832d6a57fcf7c3237fe
|
|
7
|
+
data.tar.gz: a5993427b1e28efaa8849440ff0a16e724cb25dc746d0f49292bad78c2d1f364f5526e57d30e73361c236c1460c248ddb4eaf7c32ec370965622583982881232
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "bundler/setup"
|
|
5
|
+
require "thor/interactive"
|
|
6
|
+
|
|
7
|
+
class CompletionDemo < Thor
|
|
8
|
+
include Thor::Interactive::Command
|
|
9
|
+
|
|
10
|
+
configure_interactive(
|
|
11
|
+
prompt: "demo> "
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
desc "process FILE", "Process a file"
|
|
15
|
+
option :output, type: :string, aliases: "-o", desc: "Output file"
|
|
16
|
+
option :format, type: :string, enum: ["json", "xml", "yaml"], desc: "Output format"
|
|
17
|
+
option :verbose, type: :boolean, aliases: "-v", desc: "Verbose output"
|
|
18
|
+
def process(file)
|
|
19
|
+
puts "Processing: #{file}"
|
|
20
|
+
puts "Output to: #{options[:output]}" if options[:output]
|
|
21
|
+
puts "Format: #{options[:format]}" if options[:format]
|
|
22
|
+
puts "Verbose: ON" if options[:verbose]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
desc "convert INPUT OUTPUT", "Convert file format"
|
|
26
|
+
option :from, type: :string, required: true, desc: "Source format"
|
|
27
|
+
option :to, type: :string, required: true, desc: "Target format"
|
|
28
|
+
def convert(input, output)
|
|
29
|
+
puts "Converting: #{input} -> #{output}"
|
|
30
|
+
puts "Format: #{options[:from]} -> #{options[:to]}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
desc "read FILE", "Read a file"
|
|
34
|
+
def read(file)
|
|
35
|
+
if File.exist?(file)
|
|
36
|
+
puts "Reading #{file}:"
|
|
37
|
+
puts "-" * 40
|
|
38
|
+
puts File.read(file).lines.first(10).join
|
|
39
|
+
puts "-" * 40
|
|
40
|
+
puts "(Showing first 10 lines)"
|
|
41
|
+
else
|
|
42
|
+
puts "File not found: #{file}"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
desc "list [DIR]", "List files in directory"
|
|
47
|
+
option :all, type: :boolean, aliases: "-a", desc: "Show hidden files"
|
|
48
|
+
option :long, type: :boolean, aliases: "-l", desc: "Long format"
|
|
49
|
+
def list(dir = ".")
|
|
50
|
+
puts "Listing files in: #{dir}"
|
|
51
|
+
|
|
52
|
+
pattern = options[:all] ? "*" : "[^.]*"
|
|
53
|
+
files = Dir.glob(File.join(dir, pattern))
|
|
54
|
+
|
|
55
|
+
if options[:long]
|
|
56
|
+
files.each do |file|
|
|
57
|
+
stat = File.stat(file)
|
|
58
|
+
type = File.directory?(file) ? "d" : "-"
|
|
59
|
+
size = stat.size.to_s.rjust(10)
|
|
60
|
+
name = File.basename(file)
|
|
61
|
+
name += "/" if File.directory?(file)
|
|
62
|
+
puts "#{type} #{size} #{name}"
|
|
63
|
+
end
|
|
64
|
+
else
|
|
65
|
+
files.each { |f| puts File.basename(f) }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
desc "test", "Test completion features"
|
|
70
|
+
def test
|
|
71
|
+
puts <<~TEST
|
|
72
|
+
|
|
73
|
+
=== Path Completion Demo ===
|
|
74
|
+
|
|
75
|
+
This demo showcases the new completion features:
|
|
76
|
+
|
|
77
|
+
1. PATH COMPLETION
|
|
78
|
+
Start typing a path and press TAB:
|
|
79
|
+
/process <TAB> # Shows files in current directory
|
|
80
|
+
/process ex<TAB> # Completes to 'examples/'
|
|
81
|
+
/process ~/Doc<TAB> # Completes home directory paths
|
|
82
|
+
/process ./lib/<TAB> # Shows files in lib directory
|
|
83
|
+
|
|
84
|
+
2. OPTION COMPLETION
|
|
85
|
+
Type - or -- and press TAB:
|
|
86
|
+
/process file.txt --<TAB> # Shows all options
|
|
87
|
+
/process file.txt --v<TAB> # Completes to --verbose
|
|
88
|
+
/process file.txt -<TAB> # Shows short options
|
|
89
|
+
|
|
90
|
+
3. SMART DETECTION
|
|
91
|
+
After file options, paths are completed:
|
|
92
|
+
/process --output <TAB> # Completes paths
|
|
93
|
+
/process -o <TAB> # Also completes paths
|
|
94
|
+
|
|
95
|
+
4. COMMAND COMPLETION
|
|
96
|
+
Still works as before:
|
|
97
|
+
/proc<TAB> # Completes to /process
|
|
98
|
+
/con<TAB> # Completes to /convert
|
|
99
|
+
|
|
100
|
+
TRY THESE EXAMPLES:
|
|
101
|
+
|
|
102
|
+
Basic file completion:
|
|
103
|
+
/read <TAB>
|
|
104
|
+
/read README<TAB>
|
|
105
|
+
/read lib/<TAB>
|
|
106
|
+
|
|
107
|
+
Option completion:
|
|
108
|
+
/process file.txt --<TAB>
|
|
109
|
+
/process file.txt --verb<TAB>
|
|
110
|
+
/convert input.txt output.json --<TAB>
|
|
111
|
+
|
|
112
|
+
Path after options:
|
|
113
|
+
/process --output <TAB>
|
|
114
|
+
/process -o ~/Desktop/<TAB>
|
|
115
|
+
|
|
116
|
+
Directory listing:
|
|
117
|
+
/list <TAB>
|
|
118
|
+
/list examples/<TAB>
|
|
119
|
+
/list --<TAB>
|
|
120
|
+
|
|
121
|
+
TEST
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
desc "create_test_files", "Create test files for demo"
|
|
125
|
+
def create_test_files
|
|
126
|
+
puts "Creating test files..."
|
|
127
|
+
|
|
128
|
+
# Create some test files
|
|
129
|
+
files = [
|
|
130
|
+
"test_file.txt",
|
|
131
|
+
"test_data.json",
|
|
132
|
+
"test_doc.md",
|
|
133
|
+
"test_config.yaml",
|
|
134
|
+
"test_log.log"
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
files.each do |file|
|
|
138
|
+
File.write(file, "Test content for #{file}\n")
|
|
139
|
+
puts " Created: #{file}"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Create a test directory
|
|
143
|
+
Dir.mkdir("test_dir") unless Dir.exist?("test_dir")
|
|
144
|
+
File.write("test_dir/nested.txt", "Nested file content\n")
|
|
145
|
+
puts " Created: test_dir/nested.txt"
|
|
146
|
+
|
|
147
|
+
puts "\nTest files created! Try tab completion with these files."
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
desc "clean_test_files", "Remove test files"
|
|
151
|
+
def clean_test_files
|
|
152
|
+
puts "Cleaning up test files..."
|
|
153
|
+
|
|
154
|
+
files = [
|
|
155
|
+
"test_file.txt",
|
|
156
|
+
"test_data.json",
|
|
157
|
+
"test_doc.md",
|
|
158
|
+
"test_config.yaml",
|
|
159
|
+
"test_log.log",
|
|
160
|
+
"test_dir/nested.txt"
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
files.each do |file|
|
|
164
|
+
if File.exist?(file)
|
|
165
|
+
File.delete(file)
|
|
166
|
+
puts " Removed: #{file}"
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
Dir.rmdir("test_dir") if Dir.exist?("test_dir") && Dir.empty?("test_dir")
|
|
171
|
+
puts "Cleanup complete!"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
default_task :test
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
if __FILE__ == $0
|
|
178
|
+
puts "Path Completion Demo"
|
|
179
|
+
puts "==================="
|
|
180
|
+
puts
|
|
181
|
+
puts "Tab completion now supports:"
|
|
182
|
+
puts " • File and directory paths"
|
|
183
|
+
puts " • Command option names"
|
|
184
|
+
puts " • Smart detection of when to complete paths"
|
|
185
|
+
puts
|
|
186
|
+
puts "Type '/test' for examples or '/create_test_files' to create test files"
|
|
187
|
+
puts "Press TAB at any time to see completions!"
|
|
188
|
+
puts
|
|
189
|
+
|
|
190
|
+
CompletionDemo.new.interactive
|
|
191
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "bundler/setup"
|
|
5
|
+
require "thor/interactive"
|
|
6
|
+
|
|
7
|
+
class EdgeCaseTest < Thor
|
|
8
|
+
include Thor::Interactive::Command
|
|
9
|
+
|
|
10
|
+
configure_interactive(
|
|
11
|
+
prompt: "test> ",
|
|
12
|
+
default_handler: ->(input, instance) {
|
|
13
|
+
puts "DEFAULT HANDLER: '#{input}'"
|
|
14
|
+
}
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
desc "topics [FILTER]", "List topics"
|
|
18
|
+
option :summarize, type: :boolean, desc: "Summarize topics"
|
|
19
|
+
option :format, type: :string, desc: "Output format"
|
|
20
|
+
def topics(filter = nil)
|
|
21
|
+
puts "TOPICS COMMAND:"
|
|
22
|
+
puts " Filter: #{filter.inspect}"
|
|
23
|
+
puts " Options: #{options.to_h.inspect}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
desc "echo TEXT", "Echo text"
|
|
27
|
+
def echo(text)
|
|
28
|
+
puts "ECHO: '#{text}'"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
desc "test", "Run edge case tests"
|
|
32
|
+
def test
|
|
33
|
+
puts "\n=== Edge Case Tests ==="
|
|
34
|
+
|
|
35
|
+
puts "\n1. Unknown option:"
|
|
36
|
+
puts " Input: /topics --unknown-option"
|
|
37
|
+
process_input("/topics --unknown-option")
|
|
38
|
+
|
|
39
|
+
puts "\n2. Unknown option with value:"
|
|
40
|
+
puts " Input: /topics --unknown-option value"
|
|
41
|
+
process_input("/topics --unknown-option value")
|
|
42
|
+
|
|
43
|
+
puts "\n3. Mixed text and option-like strings:"
|
|
44
|
+
puts " Input: /topics The start of a string --option the rest"
|
|
45
|
+
process_input("/topics The start of a string --option the rest")
|
|
46
|
+
|
|
47
|
+
puts "\n4. Valid option mixed with text:"
|
|
48
|
+
puts " Input: /topics Some text --summarize more text"
|
|
49
|
+
process_input("/topics Some text --summarize more text")
|
|
50
|
+
|
|
51
|
+
puts "\n5. Option-like text in echo command (no options defined):"
|
|
52
|
+
puts " Input: /echo This has --what-looks-like an option"
|
|
53
|
+
process_input("/echo This has --what-looks-like an option")
|
|
54
|
+
|
|
55
|
+
puts "\n6. Real option after text:"
|
|
56
|
+
puts " Input: /topics AI and ML --format json"
|
|
57
|
+
process_input("/topics AI and ML --format json")
|
|
58
|
+
|
|
59
|
+
puts "\n=== End Tests ==="
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def process_input(input)
|
|
65
|
+
# Simulate what the shell does
|
|
66
|
+
if input.start_with?('/')
|
|
67
|
+
send(:handle_slash_command, input[1..-1])
|
|
68
|
+
else
|
|
69
|
+
@default_handler.call(input, self)
|
|
70
|
+
end
|
|
71
|
+
rescue => e
|
|
72
|
+
puts " ERROR: #{e.message}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def handle_slash_command(command_input)
|
|
76
|
+
parts = command_input.split(/\s+/, 2)
|
|
77
|
+
command = parts[0]
|
|
78
|
+
args = parts[1] || ""
|
|
79
|
+
|
|
80
|
+
if command == "topics"
|
|
81
|
+
# Parse with shellwords
|
|
82
|
+
require 'shellwords'
|
|
83
|
+
parsed = Shellwords.split(args) rescue args.split(/\s+/)
|
|
84
|
+
invoke("topics", parsed)
|
|
85
|
+
elsif command == "echo"
|
|
86
|
+
echo(args)
|
|
87
|
+
end
|
|
88
|
+
rescue => e
|
|
89
|
+
puts " ERROR: #{e.message}"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
if __FILE__ == $0
|
|
94
|
+
puts "Edge Case Testing"
|
|
95
|
+
puts "================="
|
|
96
|
+
|
|
97
|
+
# Create instance and run tests
|
|
98
|
+
app = EdgeCaseTest.new
|
|
99
|
+
app.test
|
|
100
|
+
|
|
101
|
+
puts "\nInteractive mode - try these:"
|
|
102
|
+
puts " /topics --unknown-option"
|
|
103
|
+
puts " /topics The start --option the rest"
|
|
104
|
+
puts
|
|
105
|
+
|
|
106
|
+
app.interactive
|
|
107
|
+
end
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "bundler/setup"
|
|
5
|
+
require "thor/interactive"
|
|
6
|
+
|
|
7
|
+
class OptionsDemo < Thor
|
|
8
|
+
include Thor::Interactive::Command
|
|
9
|
+
|
|
10
|
+
configure_interactive(
|
|
11
|
+
prompt: "opts> "
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
desc "process FILE", "Process a file with various options"
|
|
15
|
+
option :verbose, type: :boolean, aliases: "-v", desc: "Enable verbose output"
|
|
16
|
+
option :format, type: :string, default: "json", enum: ["json", "xml", "yaml", "csv"], desc: "Output format"
|
|
17
|
+
option :output, type: :string, aliases: "-o", desc: "Output file"
|
|
18
|
+
option :limit, type: :numeric, aliases: "-l", desc: "Limit number of results"
|
|
19
|
+
option :skip, type: :numeric, default: 0, desc: "Skip N results"
|
|
20
|
+
option :tags, type: :array, desc: "Tags to filter by"
|
|
21
|
+
option :config, type: :hash, desc: "Additional configuration"
|
|
22
|
+
option :dry_run, type: :boolean, desc: "Don't actually process, just show what would happen"
|
|
23
|
+
def process(file)
|
|
24
|
+
if options[:dry_run]
|
|
25
|
+
puts "DRY RUN MODE - No actual processing"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
puts "Processing file: #{file}"
|
|
29
|
+
puts "=" * 50
|
|
30
|
+
|
|
31
|
+
if options[:verbose]
|
|
32
|
+
puts "Verbose mode enabled"
|
|
33
|
+
puts "All options:"
|
|
34
|
+
options.each do |key, value|
|
|
35
|
+
puts " #{key}: #{value.inspect}"
|
|
36
|
+
end
|
|
37
|
+
puts
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
puts "Format: #{options[:format]}"
|
|
41
|
+
puts "Output: #{options[:output] || 'stdout'}"
|
|
42
|
+
|
|
43
|
+
if options[:limit]
|
|
44
|
+
puts "Limiting to #{options[:limit]} results"
|
|
45
|
+
puts "Skipping first #{options[:skip]} results" if options[:skip] > 0
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
if options[:tags] && !options[:tags].empty?
|
|
49
|
+
puts "Filtering by tags: #{options[:tags].join(', ')}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
if options[:config] && !options[:config].empty?
|
|
53
|
+
puts "Configuration:"
|
|
54
|
+
options[:config].each do |key, value|
|
|
55
|
+
puts " #{key}: #{value}"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
unless options[:dry_run]
|
|
60
|
+
puts "\n[Simulating processing...]"
|
|
61
|
+
sleep(1)
|
|
62
|
+
puts "✓ Processing complete!"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
desc "search QUERY", "Search with options"
|
|
67
|
+
option :case_sensitive, type: :boolean, aliases: "-c", desc: "Case sensitive search"
|
|
68
|
+
option :regex, type: :boolean, aliases: "-r", desc: "Use regex"
|
|
69
|
+
option :files, type: :array, aliases: "-f", desc: "Files to search in"
|
|
70
|
+
option :max_results, type: :numeric, default: 10, desc: "Maximum results"
|
|
71
|
+
def search(query)
|
|
72
|
+
puts "Searching for: #{query}"
|
|
73
|
+
puts "Options:"
|
|
74
|
+
puts " Case sensitive: #{options[:case_sensitive] ? 'Yes' : 'No'}"
|
|
75
|
+
puts " Regex mode: #{options[:regex] ? 'Yes' : 'No'}"
|
|
76
|
+
puts " Max results: #{options[:max_results]}"
|
|
77
|
+
|
|
78
|
+
if options[:files]
|
|
79
|
+
puts " Searching in files: #{options[:files].join(', ')}"
|
|
80
|
+
else
|
|
81
|
+
puts " Searching in all files"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Simulate search
|
|
85
|
+
results = [
|
|
86
|
+
"result_1.txt:10: #{query} found here",
|
|
87
|
+
"result_2.txt:25: another #{query} match",
|
|
88
|
+
"result_3.txt:40: #{query} appears again"
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
puts "\nResults:"
|
|
92
|
+
results.take(options[:max_results]).each do |result|
|
|
93
|
+
puts " #{result}"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
desc "convert INPUT OUTPUT", "Convert file from one format to another"
|
|
98
|
+
option :from, type: :string, required: true, desc: "Input format"
|
|
99
|
+
option :to, type: :string, required: true, desc: "Output format"
|
|
100
|
+
option :preserve_metadata, type: :boolean, desc: "Preserve file metadata"
|
|
101
|
+
option :compression, type: :string, enum: ["none", "gzip", "bzip2", "xz"], default: "none"
|
|
102
|
+
def convert(input, output)
|
|
103
|
+
puts "Converting: #{input} → #{output}"
|
|
104
|
+
puts "Format: #{options[:from]} → #{options[:to]}"
|
|
105
|
+
puts "Compression: #{options[:compression]}"
|
|
106
|
+
puts "Preserve metadata: #{options[:preserve_metadata] ? 'Yes' : 'No'}"
|
|
107
|
+
|
|
108
|
+
# Validation
|
|
109
|
+
unless File.exist?(input)
|
|
110
|
+
puts "Error: Input file '#{input}' not found"
|
|
111
|
+
return
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
puts "\n[Simulating conversion...]"
|
|
115
|
+
puts "✓ Conversion complete!"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
desc "test", "Test various option formats"
|
|
119
|
+
def test
|
|
120
|
+
puts "\n=== Option Parsing Test Cases ==="
|
|
121
|
+
puts "\nTry these commands to test option parsing:\n"
|
|
122
|
+
|
|
123
|
+
examples = [
|
|
124
|
+
"/process file.txt --verbose",
|
|
125
|
+
"/process file.txt -v",
|
|
126
|
+
"/process data.json --format xml --output result.xml",
|
|
127
|
+
"/process data.json --format=yaml --limit=100",
|
|
128
|
+
"/process file.txt --tags important urgent todo",
|
|
129
|
+
"/process file.txt --config env:production db:postgres",
|
|
130
|
+
"/process file.txt -v --format csv -o output.csv --limit 50",
|
|
131
|
+
"/process file.txt --dry-run --verbose",
|
|
132
|
+
"",
|
|
133
|
+
"/search 'hello world' --case-sensitive",
|
|
134
|
+
"/search pattern -r -f file1.txt file2.txt file3.txt",
|
|
135
|
+
"/search query --max-results 5",
|
|
136
|
+
"",
|
|
137
|
+
"/convert input.json output.yaml --from json --to yaml",
|
|
138
|
+
"/convert data.csv data.json --from=csv --to=json --compression=gzip"
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
examples.each do |example|
|
|
142
|
+
puts example.empty? ? "" : " #{example}"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
puts "\n=== Features Demonstrated ==="
|
|
146
|
+
puts "✓ Boolean options (--verbose, -v)"
|
|
147
|
+
puts "✓ String options (--format xml, --format=xml)"
|
|
148
|
+
puts "✓ Numeric options (--limit 100)"
|
|
149
|
+
puts "✓ Array options (--tags tag1 tag2 tag3)"
|
|
150
|
+
puts "✓ Hash options (--config key1:val1 key2:val2)"
|
|
151
|
+
puts "✓ Required options (--from, --to in convert)"
|
|
152
|
+
puts "✓ Default values (format: json, skip: 0)"
|
|
153
|
+
puts "✓ Enum validation (format must be json/xml/yaml/csv)"
|
|
154
|
+
puts "✓ Short aliases (-v for --verbose, -o for --output)"
|
|
155
|
+
puts "✓ Multiple options in one command"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
desc "help_options", "Explain how options work in interactive mode"
|
|
159
|
+
def help_options
|
|
160
|
+
puts <<~HELP
|
|
161
|
+
|
|
162
|
+
=== Option Parsing in thor-interactive ===
|
|
163
|
+
|
|
164
|
+
Thor-interactive now fully supports Thor's option parsing!
|
|
165
|
+
|
|
166
|
+
BASIC USAGE:
|
|
167
|
+
/command arg1 arg2 --option value --flag
|
|
168
|
+
|
|
169
|
+
OPTION TYPES:
|
|
170
|
+
Boolean: --verbose or -v (no value needed)
|
|
171
|
+
String: --format json or --format=json
|
|
172
|
+
Numeric: --limit 100 or --limit=100
|
|
173
|
+
Array: --tags tag1 tag2 tag3
|
|
174
|
+
Hash: --config key1:val1 key2:val2
|
|
175
|
+
|
|
176
|
+
FEATURES:
|
|
177
|
+
• Long form: --option-name value
|
|
178
|
+
• Short form: -o value
|
|
179
|
+
• Equals syntax: --option=value
|
|
180
|
+
• Multiple options: --opt1 val1 --opt2 val2
|
|
181
|
+
• Default values: Defined in Thor command
|
|
182
|
+
• Required options: Must be provided
|
|
183
|
+
• Enum validation: Limited to specific values
|
|
184
|
+
|
|
185
|
+
BACKWARD COMPATIBILITY:
|
|
186
|
+
• Commands without options work as before
|
|
187
|
+
• Natural language still works for text commands
|
|
188
|
+
• Single-text commands preserve their behavior
|
|
189
|
+
• Default handler unaffected
|
|
190
|
+
|
|
191
|
+
EXAMPLES:
|
|
192
|
+
# Boolean flag
|
|
193
|
+
/process file.txt --verbose
|
|
194
|
+
|
|
195
|
+
# String option with equals
|
|
196
|
+
/process file.txt --format=xml
|
|
197
|
+
|
|
198
|
+
# Multiple options
|
|
199
|
+
/process file.txt -v --format csv --limit 10
|
|
200
|
+
|
|
201
|
+
# Array option
|
|
202
|
+
/search term --files file1.txt file2.txt
|
|
203
|
+
|
|
204
|
+
# Hash option
|
|
205
|
+
/deploy --config env:prod region:us-west
|
|
206
|
+
|
|
207
|
+
HOW IT WORKS:
|
|
208
|
+
1. Thor-interactive detects if command has options defined
|
|
209
|
+
2. Uses Thor's option parser to parse the arguments
|
|
210
|
+
3. Separates options from regular arguments
|
|
211
|
+
4. Sets options hash on Thor instance
|
|
212
|
+
5. Calls command with remaining arguments
|
|
213
|
+
6. Falls back to original behavior if parsing fails
|
|
214
|
+
|
|
215
|
+
NATURAL LANGUAGE:
|
|
216
|
+
Natural language input still works! Options are only
|
|
217
|
+
parsed for Thor commands that define them. Text sent
|
|
218
|
+
to default handlers is unchanged.
|
|
219
|
+
|
|
220
|
+
HELP
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
default_task :test
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
if __FILE__ == $0
|
|
227
|
+
puts "Thor Options Demo"
|
|
228
|
+
puts "=================="
|
|
229
|
+
puts
|
|
230
|
+
puts "This demo shows Thor option parsing in interactive mode."
|
|
231
|
+
puts "Type '/test' to see examples or '/help_options' for details."
|
|
232
|
+
puts
|
|
233
|
+
|
|
234
|
+
OptionsDemo.new.interactive
|
|
235
|
+
end
|
|
@@ -149,10 +149,182 @@ class Thor
|
|
|
149
149
|
end
|
|
150
150
|
|
|
151
151
|
def complete_command_options(text, preposing)
|
|
152
|
-
#
|
|
153
|
-
|
|
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
|
|
154
263
|
[]
|
|
155
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
|
|
156
328
|
|
|
157
329
|
def process_input(input)
|
|
158
330
|
# Handle completely empty input
|
|
@@ -205,8 +377,8 @@ class Thor
|
|
|
205
377
|
if thor_command?(command_word)
|
|
206
378
|
task = @thor_class.tasks[command_word]
|
|
207
379
|
|
|
208
|
-
if task && single_text_command?(task)
|
|
209
|
-
# Single text command - pass everything after command as one argument
|
|
380
|
+
if task && single_text_command?(task) && !task.options.any?
|
|
381
|
+
# Single text command without options - pass everything after command as one argument
|
|
210
382
|
text_part = command_input.sub(/^#{Regexp.escape(command_word)}\s*/, '')
|
|
211
383
|
if text_part.empty?
|
|
212
384
|
invoke_thor_command(command_word, [])
|
|
@@ -295,13 +467,32 @@ class Thor
|
|
|
295
467
|
if command == "help"
|
|
296
468
|
show_help(args.first)
|
|
297
469
|
else
|
|
298
|
-
#
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
|
302
489
|
else
|
|
303
|
-
#
|
|
304
|
-
@thor_instance.
|
|
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
|
|
305
496
|
end
|
|
306
497
|
end
|
|
307
498
|
rescue SystemExit => e
|
|
@@ -320,9 +511,53 @@ class Thor
|
|
|
320
511
|
puts "Error: #{e.message}"
|
|
321
512
|
puts "Command: #{command}, Args: #{args.inspect}" if ENV["DEBUG"]
|
|
322
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
|
|
323
550
|
|
|
324
551
|
def show_help(command = nil)
|
|
325
|
-
if command && @thor_class.
|
|
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)
|
|
326
561
|
@thor_class.command_help(Thor::Base.shell.new, command)
|
|
327
562
|
else
|
|
328
563
|
puts "Available commands (prefix with /):"
|
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.5
|
|
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-01-28 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: thor
|
|
@@ -54,8 +54,11 @@ files:
|
|
|
54
54
|
- README.md
|
|
55
55
|
- Rakefile
|
|
56
56
|
- examples/README.md
|
|
57
|
+
- examples/completion_demo.rb
|
|
57
58
|
- examples/demo_session.rb
|
|
59
|
+
- examples/edge_case_test.rb
|
|
58
60
|
- examples/nested_example.rb
|
|
61
|
+
- examples/options_demo.rb
|
|
59
62
|
- examples/sample_app.rb
|
|
60
63
|
- examples/signal_demo.rb
|
|
61
64
|
- examples/test_interactive.rb
|