Turn any Thor CLI into an interactive REPL with persistent state and auto-completion.
Thor::Interactive automatically converts your existing Thor command-line applications into interactive REPLs, maintaining state between commands and providing auto-completion for commands and parameters. Perfect for applications that benefit from persistent sessions like RAG pipelines, database tools, or any CLI that maintains caches or connections.
- Zero Configuration: Works with any existing Thor application without modifications
- State Persistence: Maintains class variables and instance state between commands
- Auto-completion: Tab completion for command names and basic parameter support
- Default Handlers: Configurable fallback for non-command input
- Command History: Persistent readline history with up/down arrow navigation
- Both Modes: Supports both traditional CLI usage and interactive REPL mode
- Graceful Exit: Proper handling of Ctrl+C interrupts and Ctrl+D/exit commands
Add to your application's Gemfile:
gem 'thor-interactive'
Or install directly:
gem install thor-interactive
Add one line to your Thor class to get an interactive
command:
require 'thor'
require 'thor/interactive'
class MyApp < Thor
include Thor::Interactive::Command
# Your existing Thor commands work unchanged
desc "hello NAME", "Say hello"
def hello(name)
puts "Hello #{name}!"
end
end
Now your app supports both modes:
# Normal CLI usage (unchanged)
ruby myapp.rb hello World
# New interactive mode with slash commands
ruby myapp.rb interactive
myapp> /hello Alice
Hello Alice!
myapp> Natural language input goes to default handler
myapp> exit
Start an interactive shell programmatically:
require 'thor/interactive'
class MyApp < Thor
desc "hello NAME", "Say hello"
def hello(name)
puts "Hello #{name}!"
end
end
# Start interactive shell
Thor::Interactive.start(MyApp)
The key benefit is maintaining state between commands:
class RAGApp < Thor
include Thor::Interactive::Command
# These persist between commands in interactive mode
class_variable_set(:@@llm_client, nil)
class_variable_set(:@@conversation_history, [])
desc "ask TEXT", "Ask the LLM a question"
def ask(text)
# Initialize once, reuse across commands
@@llm_client ||= expensive_llm_initialization
response = @@llm_client.chat(text)
@@conversation_history << {input: text, output: response}
puts response
end
desc "history", "Show conversation history"
def history
@@conversation_history.each_with_index do |item, i|
puts "#{i+1}. Q: #{item[:input]}"
puts " A: #{item[:output]}"
end
end
end
In interactive mode:
ruby rag_app.rb interactive
rag> /ask What is Ruby?
# LLM initializes once
Ruby is a programming language...
rag> /ask Tell me more
# LLM client reused, conversation context maintained
Based on our previous discussion about Ruby...
rag> What's the difference between Ruby and Python?
# Natural language goes directly to default handler (ask command)
Ruby and Python differ in several ways...
rag> /history
1. Q: What is Ruby?
A: Ruby is a programming language...
2. Q: Tell me more
A: Based on our previous discussion about Ruby...
3. Q: What's the difference between Ruby and Python?
A: Ruby and Python differ in several ways...
Configure interactive behavior:
class MyApp < Thor
include Thor::Interactive::Command
configure_interactive(
prompt: "myapp> ", # Custom prompt
allow_nested: false, # Prevent nested sessions (default)
nested_prompt_format: "[L%d] %s", # Format for nested prompts (if allowed)
default_handler: proc do |input, thor_instance|
# Handle unrecognized input
# IMPORTANT: Use direct method calls, NOT invoke(), to avoid Thor's
# silent failure on repeated calls to the same method
thor_instance.search(input) # ✅ Works repeatedly
# thor_instance.invoke(:search, [input]) # ❌ Fails after first call
end
)
desc "search QUERY", "Search for something"
def search(query)
puts "Searching for: #{query}"
end
end
Now unrecognized input gets sent to the search command:
myapp> hello world
Hello world!
myapp> some random text
Searching for: some random text
By default, thor-interactive prevents nested interactive sessions to avoid confusion:
class MyApp < Thor
include Thor::Interactive::Command
configure_interactive(
prompt: "myapp> ",
allow_nested: false # Default behavior
)
end
If you try to run interactive
while already in an interactive session:
myapp> interactive
Already in an interactive session.
To allow nested sessions, configure with: configure_interactive(allow_nested: true)
For advanced use cases, you can enable nested sessions:
class AdvancedApp < Thor
include Thor::Interactive::Command
configure_interactive(
prompt: "advanced> ",
allow_nested: true,
nested_prompt_format: "[Level %d] %s" # Optional custom format
)
end
With nested sessions enabled:
$ ruby advanced_app.rb interactive
AdvancedApp Interactive Shell
Type 'help' for available commands, 'exit' to quit
advanced> interactive
AdvancedApp Interactive Shell (nested level 2)
Type 'exit' to return to previous level, or 'help' for commands
[Level 2] advanced> hello nested
Hello nested!
[Level 2] advanced> exit
Exiting nested session...
advanced> exit
Goodbye!
Always use direct method calls in default handlers, NOT invoke()
:
# ✅ CORRECT - Works for repeated calls
configure_interactive(
default_handler: proc do |input, thor_instance|
thor_instance.ask(input) # Direct method call
end
)
# ❌ WRONG - Silent failure after first call
configure_interactive(
default_handler: proc do |input, thor_instance|
thor_instance.invoke(:ask, [input]) # Thor's invoke fails silently on repeat calls
end
)
Why: Thor's invoke
method has internal deduplication that prevents repeated calls to the same method on the same instance. This causes silent failures in interactive mode where users expect to be able to repeat commands.
Pass options to the interactive command:
ruby myapp.rb interactive --prompt="custom> " --history-file=~/.my_history
Use the same gem with different Thor applications:
# Database CLI
class DBApp < Thor
include Thor::Interactive::Command
configure_interactive(prompt: "db> ")
end
# API Testing CLI
class APIApp < Thor
include Thor::Interactive::Command
configure_interactive(prompt: "api> ")
end
Use programmatically without including the module:
default_handler = proc do |input, instance|
puts "You said: #{input}"
end
Thor::Interactive.start(MyThorApp,
prompt: "custom> ",
default_handler: default_handler,
history_file: "~/.custom_history"
)
See the examples/
directory for complete working examples:
sample_app.rb
- Demonstrates all features with a simple CLItest_interactive.rb
- Test script showing the API
Run the example:
cd examples
ruby sample_app.rb interactive
Thor::Interactive creates a persistent instance of your Thor class and invokes commands on that same instance, preserving any instance variables or class variables between commands. This is different from normal CLI usage where each command starts with a fresh instance.
The shell provides:
- Tab completion for command names
- Readline history with persistent storage
- Proper signal handling (Ctrl+C, Ctrl+D)
- Help system integration
- Configurable default handlers for non-commands
After checking out the repo:
bundle install # Install dependencies
bundle exec rspec # Run full test suite with coverage
bundle exec rake build # Build gem
open coverage/index.html # View coverage report (after running tests)
The gem includes comprehensive tests organized into unit and integration test suites with 72%+ code coverage:
# Run all tests
bundle exec rspec
# Run with detailed output
bundle exec rspec --format documentation
# View coverage report
open coverage/index.html # Detailed HTML coverage report
# Run specific test suites
bundle exec rspec spec/unit/ # Unit tests only
bundle exec rspec spec/integration/ # Integration tests only
# Run specific test files
bundle exec rspec spec/unit/shell_spec.rb
bundle exec rspec spec/integration/shell_integration_spec.rb
spec/
├── spec_helper.rb # Test configuration and shared setup
├── support/
│ ├── test_thor_apps.rb # Test Thor applications (not packaged)
│ └── capture_helpers.rb # Test utilities for I/O capture
├── unit/ # Unit tests for individual components
│ ├── shell_spec.rb # Thor::Interactive::Shell tests
│ ├── command_spec.rb # Thor::Interactive::Command mixin tests
│ └── completion_spec.rb # Completion system tests
└── integration/ # Integration tests for full workflows
└── shell_integration_spec.rb # End-to-end interactive shell tests
Tests use dedicated Thor applications in spec/support/test_thor_apps.rb
:
SimpleTestApp
- Basic Thor app with simple commandsStatefulTestApp
- App with state persistence and default handlersSubcommandTestApp
- App with Thor subcommandsOptionsTestApp
- App with various Thor options and arguments
These test apps are excluded from the packaged gem but provide comprehensive test coverage.
The examples/
directory contains working examples (these ARE packaged with the gem):
cd examples
# Run in normal CLI mode
ruby sample_app.rb help
ruby sample_app.rb hello World
ruby sample_app.rb count
ruby sample_app.rb add "Test item"
# Run in interactive mode
ruby sample_app.rb interactive
$ ruby sample_app.rb interactive
SampleApp Interactive Shell
Type 'help' for available commands, 'exit' to quit
sample> hello Alice
Hello Alice!
sample> count
Count: 1
sample> count
Count: 2 # Note: state persisted!
sample> add "Buy groceries"
Added: Buy groceries
sample> add "Walk the dog"
Added: Walk the dog
sample> list
1. Buy groceries
2. Walk the dog
sample> status
Counter: 2, Items: 2
sample> This is random text that doesn't match a command
Echo: This is random text that doesn't match a command
sample> help
Available commands:
hello Say hello to NAME
count Show and increment counter (demonstrates state persistence)
add Add item to list (demonstrates state persistence)
list Show all items
clear Clear all items
echo Echo the text back (used as default handler)
status Show application status
interactive Start an interactive REPL for this application
Special commands:
help [COMMAND] Show help for command
exit/quit/q Exit the REPL
sample> exit
Goodbye!
- State Persistence: The counter and items list maintain their values between commands
- Auto-completion: Try typing
h<TAB>
orco<TAB>
to see command completion - Default Handler: Text that doesn't match a command gets sent to the
echo
command - Command History: Use up/down arrows to navigate previous commands
- Error Handling: Try invalid commands or missing arguments
- Both Modes: The same application works as traditional CLI and interactive REPL
For applications with expensive initialization (like LLM clients), you can measure the performance benefit:
# CLI mode - initializes fresh each time
time ruby sample_app.rb count
time ruby sample_app.rb count
time ruby sample_app.rb count
# Interactive mode - initializes once, reuses state
ruby sample_app.rb interactive
# Then run: count, count, count
Enable debug mode to see backtraces on errors:
DEBUG=1 ruby sample_app.rb interactive
Or in your application:
ENV["DEBUG"] = "1"
Thor::Interactive.start(MyApp)
Bug reports and pull requests are welcome on GitHub at https://github.com/scientist-labs/thor-interactive.
The gem is available as open source under the terms of the MIT License.