thor-interactive 0.1.0.pre.1

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.
@@ -0,0 +1,144 @@
1
+ # Thor Interactive Examples
2
+
3
+ ## Sample Application
4
+
5
+ The `sample_app.rb` demonstrates both normal Thor CLI usage and interactive REPL mode.
6
+
7
+ ### Normal CLI Usage
8
+
9
+ ```bash
10
+ # Run individual commands
11
+ ruby sample_app.rb hello World
12
+ ruby sample_app.rb count
13
+ ruby sample_app.rb add "Buy groceries"
14
+ ruby sample_app.rb list
15
+ ruby sample_app.rb status
16
+ ruby sample_app.rb help
17
+ ```
18
+
19
+ ### Interactive REPL Mode
20
+
21
+ ```bash
22
+ # Start interactive mode
23
+ ruby sample_app.rb interactive
24
+ ```
25
+
26
+ Once in interactive mode:
27
+
28
+ ```
29
+ sample> hello Alice
30
+ Hello Alice!
31
+
32
+ sample> count
33
+ Count: 1
34
+
35
+ sample> count
36
+ Count: 2
37
+
38
+ sample> add "First item"
39
+ Added 'First item'. Total items: 1
40
+
41
+ sample> add "Second item"
42
+ Added 'Second item'. Total items: 2
43
+
44
+ sample> list
45
+ Items:
46
+ 1. First item
47
+ 2. Second item
48
+
49
+ sample> status
50
+ Application Status:
51
+ Counter: 2
52
+ Items in list: 2
53
+ Memory usage: 15234 KB
54
+
55
+ sample> This is unrecognized text
56
+ Echo: This is unrecognized text
57
+
58
+ sample> help
59
+ Available commands:
60
+ hello Say hello to NAME
61
+ count Show and increment counter (demonstrates state persistence)
62
+ add Add item to list (demonstrates state persistence)
63
+ list Show all items
64
+ clear Clear all items
65
+ echo Echo the text back (used as default handler)
66
+ status Show application status
67
+ interactive Start an interactive REPL for this application
68
+
69
+ Special commands:
70
+ help [COMMAND] Show help for command
71
+ exit/quit/q Exit the REPL
72
+
73
+ sample> exit
74
+ Goodbye!
75
+ ```
76
+
77
+ ## Key Features Demonstrated
78
+
79
+ ### 1. State Persistence
80
+ - The `@@counter` and `@@items` class variables maintain their state between commands in interactive mode
81
+ - In normal CLI mode, each command starts fresh
82
+
83
+ ### 2. Auto-completion
84
+ - Tab completion works for command names
85
+ - Try typing `h<TAB>` or `a<TAB>` to see completions
86
+
87
+ ### 3. Default Handler
88
+ - Text that doesn't match a command gets sent to the `echo` command
89
+ - This is configurable via the `default_handler` option
90
+
91
+ ### 4. Built-in Help
92
+ - `help` shows all available commands
93
+ - `help COMMAND` shows help for a specific command
94
+
95
+ ### 5. History
96
+ - Up/down arrows navigate command history
97
+ - History is persistent across sessions
98
+
99
+ ### 6. Graceful Exit
100
+ - Ctrl+C interrupts current operation
101
+ - Ctrl+D or `exit`/`quit`/`q` exits the REPL
102
+
103
+ ## Integration Patterns
104
+
105
+ ### Minimal Integration
106
+ ```ruby
107
+ class MyApp < Thor
108
+ include Thor::Interactive::Command
109
+
110
+ # Your existing Thor commands...
111
+ desc "hello NAME", "Say hello"
112
+ def hello(name)
113
+ puts "Hello #{name}!"
114
+ end
115
+ end
116
+
117
+ # Now available: ruby myapp.rb interactive
118
+ ```
119
+
120
+ ### Custom Configuration
121
+ ```ruby
122
+ class MyApp < Thor
123
+ include Thor::Interactive::Command
124
+
125
+ configure_interactive(
126
+ prompt: "myapp> ",
127
+ default_handler: proc do |input, thor_instance|
128
+ # Handle unrecognized input
129
+ thor_instance.invoke(:search, [input])
130
+ end
131
+ )
132
+
133
+ # Your commands...
134
+ end
135
+ ```
136
+
137
+ ### Programmatic Usage
138
+ ```ruby
139
+ # Instead of using the mixin, start programmatically
140
+ Thor::Interactive.start(MyApp,
141
+ prompt: "custom> ",
142
+ default_handler: proc { |input, instance| puts "Got: #{input}" }
143
+ )
144
+ ```
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Demo script showing the difference between CLI and interactive modes
5
+
6
+ require_relative "../lib/thor/interactive"
7
+ require_relative "sample_app"
8
+
9
+ puts "=== Thor Interactive Demo ==="
10
+ puts
11
+ puts "1. CLI Mode (each command runs fresh):"
12
+ puts " $ ruby sample_app.rb count"
13
+ puts " $ ruby sample_app.rb count"
14
+ puts
15
+
16
+ system("ruby sample_app.rb count")
17
+ system("ruby sample_app.rb count")
18
+
19
+ puts "\n Notice: Both show 'Count: 1' because state resets"
20
+ puts
21
+
22
+ puts "2. Interactive Mode (state persists):"
23
+ puts " To see interactive mode, run:"
24
+ puts " $ ruby sample_app.rb interactive"
25
+ puts
26
+ puts " Then try these commands in sequence:"
27
+ puts " sample> count"
28
+ puts " sample> count"
29
+ puts " sample> add \"first item\""
30
+ puts " sample> add \"second item\""
31
+ puts " sample> list"
32
+ puts " sample> status"
33
+ puts " sample> help"
34
+ puts " sample> This text goes to default handler"
35
+ puts " sample> exit"
36
+ puts
37
+ puts "=== Key Differences ==="
38
+ puts
39
+ puts "CLI Mode:"
40
+ puts "- Fresh Thor instance each command"
41
+ puts "- No state persistence"
42
+ puts "- Standard Thor behavior"
43
+ puts
44
+ puts "Interactive Mode:"
45
+ puts "- Single persistent Thor instance"
46
+ puts "- State maintained between commands"
47
+ puts "- Auto-completion with TAB"
48
+ puts "- Command history with arrow keys"
49
+ puts "- Default handler for unrecognized input"
50
+ puts "- Built-in help system"
51
+ puts
52
+ puts "=== Features Implemented ==="
53
+ puts
54
+ puts "✓ Generic design - works with any Thor application"
55
+ puts "✓ State persistence through single Thor instance"
56
+ puts "✓ Auto-completion for command names"
57
+ puts "✓ Configurable default handlers"
58
+ puts "✓ Command history with persistent storage"
59
+ puts "✓ Both CLI and interactive modes supported"
60
+ puts "✓ Proper error handling and signal management"
61
+ puts "✓ Help system integration"
62
+ puts "✓ Comprehensive test suite"
63
+ puts
64
+ puts "Ready for your Claude Code-like RAG application!"
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/thor/interactive"
5
+
6
+ # Example app that allows nested interactive sessions
7
+ class NestedApp < Thor
8
+ include Thor::Interactive::Command
9
+
10
+ configure_interactive(
11
+ prompt: "nested> ",
12
+ allow_nested: true, # Allow nested interactive sessions
13
+ nested_prompt_format: "[L%d] %s" # Custom format for nested prompts
14
+ )
15
+
16
+ desc "hello NAME", "Say hello"
17
+ def hello(name)
18
+ puts "Hello #{name}!"
19
+ end
20
+
21
+ desc "demo", "Show nesting demo info"
22
+ def demo
23
+ level = ENV['THOR_INTERACTIVE_LEVEL']
24
+ puts "Current nesting level: #{level || 'not in interactive mode'}"
25
+ puts "Try running 'interactive' to see nested sessions!"
26
+ end
27
+
28
+ desc "status", "Show session status"
29
+ def status
30
+ if ENV['THOR_INTERACTIVE_SESSION']
31
+ level = ENV['THOR_INTERACTIVE_LEVEL'].to_i
32
+ puts "In interactive session - Level #{level}"
33
+ else
34
+ puts "Not in interactive session"
35
+ end
36
+ end
37
+ end
38
+
39
+ # Example app that prevents nested sessions (default behavior)
40
+ class SafeApp < Thor
41
+ include Thor::Interactive::Command
42
+
43
+ configure_interactive(
44
+ prompt: "safe> ",
45
+ allow_nested: false # This is the default, but being explicit
46
+ )
47
+
48
+ desc "hello NAME", "Say hello"
49
+ def hello(name)
50
+ puts "Hello #{name}!"
51
+ end
52
+
53
+ desc "demo", "Show nesting prevention"
54
+ def demo
55
+ puts "This app prevents nested sessions."
56
+ puts "Try running 'interactive' to see the protection!"
57
+ end
58
+ end
59
+
60
+ if __FILE__ == $0
61
+ puts "=== Nested Interactive Sessions Demo ==="
62
+ puts
63
+ puts "1. NestedApp - allows nested sessions:"
64
+ puts " ruby nested_example.rb nested"
65
+ puts
66
+ puts "2. SafeApp - prevents nested sessions:"
67
+ puts " ruby nested_example.rb safe"
68
+ puts
69
+
70
+ case ARGV[0]
71
+ when "nested"
72
+ ARGV.shift
73
+ NestedApp.start(ARGV)
74
+ when "safe"
75
+ ARGV.shift
76
+ SafeApp.start(ARGV)
77
+ else
78
+ puts "Usage: ruby nested_example.rb [nested|safe] [command]"
79
+ end
80
+ end
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "thor"
5
+ require_relative "../lib/thor/interactive"
6
+
7
+ # Example Thor application that demonstrates both normal CLI usage
8
+ # and interactive REPL mode
9
+ class SampleApp < Thor
10
+ include Thor::Interactive::Command
11
+
12
+ # Configure interactive mode
13
+ configure_interactive(
14
+ prompt: "sample> ",
15
+ allow_nested: false, # Prevent nested interactive sessions by default
16
+ default_handler: proc do |input, thor_instance|
17
+ # Send unrecognized input to the 'echo' command
18
+ thor_instance.invoke(:echo, [input])
19
+ end
20
+ )
21
+
22
+ # Class variable to demonstrate state persistence in interactive mode
23
+ class_variable_set(:@@counter, 0)
24
+ class_variable_set(:@@items, [])
25
+
26
+ desc "hello NAME", "Say hello to NAME"
27
+ def hello(name)
28
+ puts "Hello #{name}!"
29
+ end
30
+
31
+ desc "count", "Show and increment counter (demonstrates state persistence)"
32
+ def count
33
+ @@counter += 1
34
+ puts "Count: #{@@counter}"
35
+ end
36
+
37
+ desc "add ITEM", "Add item to list (demonstrates state persistence)"
38
+ def add(item)
39
+ @@items << item
40
+ puts "Added '#{item}'. Total items: #{@@items.length}"
41
+ end
42
+
43
+ desc "list", "Show all items"
44
+ def list
45
+ if @@items.empty?
46
+ puts "No items in the list"
47
+ else
48
+ puts "Items:"
49
+ @@items.each_with_index do |item, index|
50
+ puts " #{index + 1}. #{item}"
51
+ end
52
+ end
53
+ end
54
+
55
+ desc "clear", "Clear all items"
56
+ def clear
57
+ @@items.clear
58
+ puts "List cleared"
59
+ end
60
+
61
+ desc "echo TEXT", "Echo the text back (used as default handler)"
62
+ def echo(*words)
63
+ text = words.join(" ")
64
+ puts "Echo: #{text}"
65
+ end
66
+
67
+ desc "status", "Show application status"
68
+ def status
69
+ puts "Application Status:"
70
+ puts " Counter: #{@@counter}"
71
+ puts " Items in list: #{@@items.length}"
72
+ puts " Memory usage: #{`ps -o rss= -p #{Process.pid}`.strip} KB" rescue "Unknown"
73
+ end
74
+ end
75
+
76
+ # This allows the file to work both ways:
77
+ # 1. Normal Thor CLI: ruby sample_app.rb hello World
78
+ # 2. Interactive mode: ruby sample_app.rb interactive
79
+ if __FILE__ == $0
80
+ SampleApp.start(ARGV)
81
+ end
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Test script to verify interactive functionality
5
+ # This simulates what would happen in interactive mode
6
+
7
+ require_relative "../lib/thor/interactive"
8
+
9
+ class TestApp < Thor
10
+ include Thor::Interactive::Command
11
+
12
+ configure_interactive(
13
+ prompt: "test> ",
14
+ default_handler: proc do |input, instance|
15
+ puts "Default handler got: #{input}"
16
+ end
17
+ )
18
+
19
+ class_variable_set(:@@counter, 0)
20
+
21
+ desc "count", "Increment and show counter"
22
+ def count
23
+ @@counter += 1
24
+ puts "Count: #{@@counter}"
25
+ end
26
+
27
+ desc "hello NAME", "Say hello"
28
+ def hello(name)
29
+ puts "Hello #{name}!"
30
+ end
31
+ end
32
+
33
+ # Test normal CLI mode
34
+ puts "=== Testing Normal CLI Mode ==="
35
+ puts "Running: hello Alice"
36
+ TestApp.start(["hello", "Alice"])
37
+ puts "\nRunning: count (twice - should both be 1)"
38
+ TestApp.start(["count"])
39
+ TestApp.start(["count"])
40
+
41
+ puts "\n=== Testing Interactive Shell Creation ==="
42
+ shell = Thor::Interactive::Shell.new(TestApp)
43
+ puts "Shell created successfully with Thor class: #{shell.thor_class}"
44
+ puts "Shell has persistent Thor instance: #{shell.thor_instance.class}"
45
+
46
+ puts "\n=== Testing Command Recognition ==="
47
+ # Test if commands are recognized
48
+ test_commands = %w[hello count help exit]
49
+ test_commands.each do |cmd|
50
+ recognized = shell.send(:thor_command?, cmd)
51
+ puts "Command '#{cmd}': #{recognized ? 'recognized' : 'not recognized'}"
52
+ end
53
+
54
+ puts "\n=== Testing Completion ==="
55
+ completions = shell.send(:complete_commands, "h")
56
+ puts "Completions for 'h': #{completions}"
57
+
58
+ puts "\n=== Interactive mode ready! ==="
59
+ puts "To test interactively, run: ruby examples/sample_app.rb interactive"
60
+ puts "Then try commands like:"
61
+ puts " count (multiple times to see state persistence)"
62
+ puts " add item1"
63
+ puts " add item2"
64
+ puts " list"
65
+ puts " hello Alice"
66
+ puts " help"
67
+ puts " exit"
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "shell"
4
+
5
+ class Thor
6
+ module Interactive
7
+ # Mixin to add an interactive command to Thor applications
8
+ module Command
9
+ def self.included(base)
10
+ base.extend(ClassMethods)
11
+ base.class_eval do
12
+ desc "interactive", "Start an interactive REPL for this application"
13
+ option :prompt, type: :string, desc: "Custom prompt for the REPL"
14
+ option :history_file, type: :string, desc: "Custom history file location"
15
+
16
+ def interactive
17
+ # Check for nested sessions unless explicitly allowed
18
+ if ENV['THOR_INTERACTIVE_SESSION'] && !self.class.interactive_options[:allow_nested]
19
+ puts "Already in an interactive session."
20
+ puts "To allow nested sessions, configure with: configure_interactive(allow_nested: true)"
21
+ return
22
+ end
23
+
24
+ opts = self.class.interactive_options.dup
25
+ opts[:prompt] = options[:prompt] || options["prompt"] if options[:prompt] || options["prompt"]
26
+ opts[:history_file] = options[:history_file] || options["history_file"] if options[:history_file] || options["history_file"]
27
+
28
+ Thor::Interactive::Shell.new(self.class, opts).start
29
+ end
30
+ end
31
+ end
32
+
33
+ module ClassMethods
34
+ def interactive_options
35
+ @interactive_options ||= { allow_nested: false }
36
+ end
37
+
38
+ def configure_interactive(**options)
39
+ interactive_options.merge!(options)
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "reline"
4
+ require "shellwords"
5
+ require "thor"
6
+
7
+ class Thor
8
+ module Interactive
9
+ class Shell
10
+ DEFAULT_PROMPT = "> "
11
+ DEFAULT_HISTORY_FILE = "~/.thor_interactive_history"
12
+ EXIT_COMMANDS = %w[exit quit q].freeze
13
+
14
+ attr_reader :thor_class, :thor_instance, :prompt
15
+
16
+ def initialize(thor_class, options = {})
17
+ @thor_class = thor_class
18
+ @thor_instance = thor_class.new
19
+
20
+ # Merge class-level interactive options if available
21
+ merged_options = {}
22
+ if thor_class.respond_to?(:interactive_options)
23
+ merged_options.merge!(thor_class.interactive_options)
24
+ end
25
+ merged_options.merge!(options)
26
+
27
+ @merged_options = merged_options
28
+ @default_handler = merged_options[:default_handler]
29
+ @prompt = merged_options[:prompt] || DEFAULT_PROMPT
30
+ @history_file = File.expand_path(merged_options[:history_file] || DEFAULT_HISTORY_FILE)
31
+
32
+ setup_completion
33
+ load_history
34
+ end
35
+
36
+ def start
37
+ # Track that we're in an interactive session
38
+ was_in_session = ENV['THOR_INTERACTIVE_SESSION']
39
+ nesting_level = ENV['THOR_INTERACTIVE_LEVEL'].to_i
40
+
41
+ ENV['THOR_INTERACTIVE_SESSION'] = 'true'
42
+ ENV['THOR_INTERACTIVE_LEVEL'] = (nesting_level + 1).to_s
43
+
44
+ # Adjust prompt for nested sessions if configured
45
+ display_prompt = @prompt
46
+ if nesting_level > 0 && @merged_options[:nested_prompt_format]
47
+ display_prompt = @merged_options[:nested_prompt_format] % [nesting_level + 1, @prompt]
48
+ elsif nesting_level > 0
49
+ display_prompt = "(#{nesting_level + 1}) #{@prompt}"
50
+ end
51
+
52
+ show_welcome(nesting_level)
53
+
54
+ loop do
55
+ line = Reline.readline(display_prompt, true)
56
+ break if should_exit?(line)
57
+
58
+ next if line.nil? || line.strip.empty?
59
+
60
+ process_input(line.strip)
61
+ rescue Interrupt
62
+ puts "\n(Interrupted - press Ctrl+D or type 'exit' to quit)"
63
+ rescue => e
64
+ puts "Error: #{e.message}"
65
+ puts e.backtrace.first(3) if ENV["DEBUG"]
66
+ end
67
+
68
+ save_history
69
+ puts nesting_level > 0 ? "Exiting nested session..." : "Goodbye!"
70
+
71
+ ensure
72
+ # Restore previous session state
73
+ if was_in_session
74
+ ENV['THOR_INTERACTIVE_SESSION'] = 'true'
75
+ ENV['THOR_INTERACTIVE_LEVEL'] = nesting_level.to_s
76
+ else
77
+ ENV.delete('THOR_INTERACTIVE_SESSION')
78
+ ENV.delete('THOR_INTERACTIVE_LEVEL')
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ def setup_completion
85
+ Reline.completion_proc = proc do |text, preposing|
86
+ complete_input(text, preposing)
87
+ end
88
+ end
89
+
90
+ def complete_input(text, preposing)
91
+ # If we're at the start of the line, complete command names
92
+ if preposing.strip.empty?
93
+ complete_commands(text)
94
+ else
95
+ # Try to complete command options or let it fall back to file completion
96
+ complete_command_options(text, preposing)
97
+ end
98
+ end
99
+
100
+ def complete_commands(text)
101
+ return [] if text.nil?
102
+
103
+ command_names = @thor_class.tasks.keys + EXIT_COMMANDS + ["help"]
104
+ command_names.select { |cmd| cmd.start_with?(text) }.sort
105
+ end
106
+
107
+ def complete_command_options(text, preposing)
108
+ # Basic implementation - can be enhanced later
109
+ # For now, just return empty array to let Reline handle file completion
110
+ []
111
+ end
112
+
113
+ def process_input(input)
114
+ # Handle completely empty input
115
+ return if input.nil? || input.strip.empty?
116
+
117
+ args = parse_input(input)
118
+ return if args.empty?
119
+
120
+ command = args.shift
121
+
122
+ if thor_command?(command)
123
+ invoke_thor_command(command, args)
124
+ elsif @default_handler
125
+ @default_handler.call(input, @thor_instance)
126
+ else
127
+ puts "Unknown command: '#{command}'. Type 'help' for available commands."
128
+ end
129
+ end
130
+
131
+ def parse_input(input)
132
+ Shellwords.split(input)
133
+ rescue ArgumentError => e
134
+ puts "Error parsing input: #{e.message}"
135
+ []
136
+ end
137
+
138
+ def thor_command?(command)
139
+ @thor_class.tasks.key?(command) ||
140
+ @thor_class.subcommand_classes.key?(command) ||
141
+ command == "help"
142
+ end
143
+
144
+ def invoke_thor_command(command, args)
145
+ # Use the persistent instance to maintain state
146
+ if command == "help"
147
+ show_help(args.first)
148
+ else
149
+ # For simple commands, call directly for state persistence
150
+ # For complex options/subcommands, this is a basic implementation
151
+ if @thor_instance.respond_to?(command)
152
+ @thor_instance.send(command, *args)
153
+ else
154
+ @thor_instance.invoke(command, args)
155
+ end
156
+ end
157
+ rescue Thor::Error => e
158
+ puts "Thor Error: #{e.message}"
159
+ rescue ArgumentError => e
160
+ puts "Thor Error: #{e.message}"
161
+ puts "Try: help #{command}" if thor_command?(command)
162
+ rescue StandardError => e
163
+ puts "Error: #{e.message}"
164
+ end
165
+
166
+ def show_help(command = nil)
167
+ if command && @thor_class.tasks.key?(command)
168
+ @thor_class.command_help(Thor::Base.shell.new, command)
169
+ else
170
+ puts "Available commands:"
171
+ @thor_class.tasks.each do |name, task|
172
+ puts " #{name.ljust(20)} #{task.description}"
173
+ end
174
+ puts
175
+ puts "Special commands:"
176
+ puts " help [COMMAND] Show help for command"
177
+ puts " exit/quit/q Exit the REPL"
178
+ puts
179
+ end
180
+ end
181
+
182
+ def should_exit?(line)
183
+ return true if line.nil? # Ctrl+D
184
+
185
+ stripped = line.strip.downcase
186
+ EXIT_COMMANDS.include?(stripped)
187
+ end
188
+
189
+ def show_welcome(nesting_level = 0)
190
+ if nesting_level > 0
191
+ puts "#{@thor_class.name} Interactive Shell (nested level #{nesting_level + 1})"
192
+ puts "Type 'exit' to return to previous level, or 'help' for commands"
193
+ else
194
+ puts "#{@thor_class.name} Interactive Shell"
195
+ puts "Type 'help' for available commands, 'exit' to quit"
196
+ end
197
+ puts
198
+ end
199
+
200
+ def load_history
201
+ return unless File.exist?(@history_file)
202
+
203
+ File.readlines(@history_file, chomp: true).each do |line|
204
+ Reline::HISTORY << line
205
+ end
206
+ rescue => e
207
+ # Ignore history loading errors
208
+ end
209
+
210
+ def save_history
211
+ return unless Reline::HISTORY.size > 0
212
+
213
+ File.write(@history_file, Reline::HISTORY.to_a.join("\n"))
214
+ rescue => e
215
+ # Ignore history saving errors
216
+ end
217
+ end
218
+ end
219
+ end