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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '049af6f0fedd7bda65c5845a878bd02afe8e9e5cf705ebad254c333e0151ecd7'
4
- data.tar.gz: '049e366b5d3d1e4d0076c7c69d69240b00f0768b3b2687d23e9482dd7bf368dd'
3
+ metadata.gz: 7166b4156516b241939081e8c9fc67aba249ceae1be85b4693d4afdb7638b08d
4
+ data.tar.gz: 8c3f92ab064d86f27a028dd685037d8e205e452ae840a674c39025d21fd8acfb
5
5
  SHA512:
6
- metadata.gz: bc5ccc2b2765f3f5c4591c181445dbc4b0ef66982c982758fb6053fa79de968c7583f019be85a747c51474e6e2f55f8834b9c4fd3314b1186dd6185a9be9e092
7
- data.tar.gz: cc284a24f5f2a24bb016651e4252a9230d89c84d0e86b382fb4b936d11063422f78338d4c0917aedcf0a6e6d9fe7dcb66f8e7fb22c479c2903f355265e00cc79
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
- # Basic implementation - can be enhanced later
153
- # For now, just return empty array to let Reline handle file completion
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
- # Always use direct method calls to avoid Thor's invoke deduplication
299
- # Thor's invoke method silently fails on subsequent calls to the same method
300
- if @thor_instance.respond_to?(command)
301
- @thor_instance.send(command, *args)
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
- # If method doesn't exist, this will raise a proper error
304
- @thor_instance.send(command, *args)
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.tasks.key?(command)
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 /):"
@@ -4,5 +4,5 @@
4
4
  # This file is separate to avoid circular dependencies during gem installation
5
5
 
6
6
  module ThorInteractive
7
- VERSION = "0.1.0.pre.3"
7
+ VERSION = "0.1.0.pre.5"
8
8
  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.3
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: 2025-09-08 00:00:00.000000000 Z
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