ruby-shell 3.1.0 → 3.2.0
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/PLUGIN_GUIDE.md +757 -0
- data/README.md +64 -1
- data/bin/rsh +433 -36
- metadata +5 -4
data/bin/rsh
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
# Web_site: http://isene.com/
|
|
9
9
|
# Github: https://github.com/isene/rsh
|
|
10
10
|
# License: Public domain
|
|
11
|
-
@version = "3.
|
|
11
|
+
@version = "3.2.0" # Plugin system: Extensible architecture with lifecycle hooks, completions, commands, and plugin management
|
|
12
12
|
|
|
13
13
|
# MODULES, CLASSES AND EXTENSIONS
|
|
14
14
|
class String # Add coloring to strings (with escaping for Readline)
|
|
@@ -144,8 +144,16 @@ begin # Initialization
|
|
|
144
144
|
@session_autosave = 0 # Auto-save interval (0 = disabled)
|
|
145
145
|
@session_last_save = Time.now.to_i # Last auto-save timestamp
|
|
146
146
|
@history_dedup = 'smart' # History dedup mode: 'off', 'full', 'smart'
|
|
147
|
+
@auto_correct = false # Auto-correct typos (default: off)
|
|
148
|
+
@slow_command_threshold = 0 # Threshold for slow command alerts (0 = disabled)
|
|
149
|
+
@plugin_dir = Dir.home + '/.rsh/plugins' # Plugins directory
|
|
150
|
+
@plugins = [] # Loaded plugin instances
|
|
151
|
+
@plugin_disabled = [] # List of disabled plugin names
|
|
152
|
+
@plugin_commands = {} # Commands added by plugins
|
|
153
|
+
# Built-in rsh commands are called with : prefix, so no need for separate tracking
|
|
147
154
|
Dir.mkdir(Dir.home + '/.rsh') unless Dir.exist?(Dir.home + '/.rsh')
|
|
148
155
|
Dir.mkdir(@session_dir) unless Dir.exist?(@session_dir)
|
|
156
|
+
Dir.mkdir(@plugin_dir) unless Dir.exist?(@plugin_dir)
|
|
149
157
|
def pre_cmd; end # User-defined function to be run BEFORE command execution
|
|
150
158
|
def post_cmd; end # User-defined function to be run AFTER command execution
|
|
151
159
|
end
|
|
@@ -153,7 +161,7 @@ end
|
|
|
153
161
|
# HELP TEXT
|
|
154
162
|
@info = <<~INFO
|
|
155
163
|
|
|
156
|
-
Hello #{@user}, welcome to rsh v3.
|
|
164
|
+
Hello #{@user}, welcome to rsh v3.2 - the Ruby SHell.
|
|
157
165
|
|
|
158
166
|
rsh does not attempt to compete with the grand old shells like bash and zsh.
|
|
159
167
|
It serves the specific needs and wants of its author. If you like it, then feel free
|
|
@@ -179,7 +187,19 @@ end
|
|
|
179
187
|
* Syntax validation - Pre-execution warnings for dangerous or malformed commands
|
|
180
188
|
* Unified command syntax - :nick, :gnick, :bm all work the same way (list/create/delete)
|
|
181
189
|
|
|
182
|
-
NEW in v3.
|
|
190
|
+
NEW in v3.2:
|
|
191
|
+
* Plugin system - Extensible architecture for custom commands, completions, and hooks
|
|
192
|
+
* Lifecycle hooks - on_startup, on_command_before, on_command_after, on_prompt
|
|
193
|
+
* Plugin management - :plugins list/reload/enable/disable/info
|
|
194
|
+
* Example plugins - git_prompt, command_logger, kubectl_completion included
|
|
195
|
+
* Auto-correct typos - :config "auto_correct" "on" automatically fixes gti → closest match
|
|
196
|
+
* Command timing alerts - :config "slow_command_threshold" "5" warns on slow commands
|
|
197
|
+
* Inline calculator - :calc 2 + 2, :calc "Math.sqrt(16)", full Ruby Math library
|
|
198
|
+
* Enhanced history - !!, !-2, !5:7 for repeat last, nth-to-last, and chaining
|
|
199
|
+
* Stats visualization - :stats --graph for colorful ASCII bar charts
|
|
200
|
+
* See PLUGIN_GUIDE.md for complete plugin development documentation
|
|
201
|
+
|
|
202
|
+
v3.1 Features:
|
|
183
203
|
* Multiple named sessions - :save_session "project" and :load_session "project"
|
|
184
204
|
* Stats export - :stats --csv or :stats --json for data analysis
|
|
185
205
|
* Session auto-save - Set @session_autosave = 300 in .rshrc for 5-min auto-save
|
|
@@ -188,8 +208,6 @@ end
|
|
|
188
208
|
* Color themes - :theme solarized|dracula|gruvbox|nord|monokai
|
|
189
209
|
* Config management - :config shows/sets history_dedup, session_autosave, etc.
|
|
190
210
|
* Environment management - :env for listing/setting/exporting environment variables
|
|
191
|
-
* Bookmark TAB completion - Bookmarks appear in TAB completion list
|
|
192
|
-
* List sessions - :list_sessions shows all saved sessions with timestamps
|
|
193
211
|
|
|
194
212
|
Config file (.rshrc) updates on exit (Ctrl-d) or not (Ctrl-e).
|
|
195
213
|
All colors are themeable in .rshrc (see github link for possibilities).
|
|
@@ -602,7 +620,7 @@ def tab(type)
|
|
|
602
620
|
:nick :gnick :bm :bookmark :stats :defun :defun?
|
|
603
621
|
:history :rmhistory :jobs :fg :bg
|
|
604
622
|
:save_session :load_session :list_sessions :delete_session :rmsession
|
|
605
|
-
:config :env :theme
|
|
623
|
+
:config :env :theme :plugins :calc
|
|
606
624
|
:info :version :help
|
|
607
625
|
]
|
|
608
626
|
search_str = @tabstr[1..-1] || "" # Remove leading :
|
|
@@ -887,6 +905,8 @@ def config(*args) # Configure rsh settings
|
|
|
887
905
|
puts "\n Current Configuration:".c(@c_prompt).b
|
|
888
906
|
puts " history_dedup: #{@history_dedup}"
|
|
889
907
|
puts " session_autosave: #{@session_autosave}s #{@session_autosave > 0 ? '(enabled)' : '(disabled)'}"
|
|
908
|
+
puts " auto_correct: #{@auto_correct ? 'on' : 'off'}"
|
|
909
|
+
puts " slow_command_threshold: #{@slow_command_threshold}s #{@slow_command_threshold > 0 ? '(enabled)' : '(disabled)'}"
|
|
890
910
|
puts " completion_limit: #{@completion_limit}"
|
|
891
911
|
puts " completion_fuzzy: #{@completion_fuzzy}"
|
|
892
912
|
puts " completion_case_sensitive: #{@completion_case_sensitive}"
|
|
@@ -907,13 +927,21 @@ def config(*args) # Configure rsh settings
|
|
|
907
927
|
@session_autosave = value.to_i
|
|
908
928
|
puts "Session auto-save set to #{value}s #{value.to_i > 0 ? '(enabled)' : '(disabled)'}"
|
|
909
929
|
rshrc
|
|
930
|
+
when 'auto_correct'
|
|
931
|
+
@auto_correct = %w[on true yes 1].include?(value.to_s.downcase)
|
|
932
|
+
puts "Auto-correct #{@auto_correct ? 'enabled' : 'disabled'}"
|
|
933
|
+
rshrc
|
|
934
|
+
when 'slow_command_threshold'
|
|
935
|
+
@slow_command_threshold = value.to_i
|
|
936
|
+
puts "Slow command threshold set to #{value}s #{value.to_i > 0 ? '(enabled)' : '(disabled)'}"
|
|
937
|
+
rshrc
|
|
910
938
|
when 'completion_limit'
|
|
911
939
|
@completion_limit = value.to_i
|
|
912
940
|
puts "Completion limit set to #{value}"
|
|
913
941
|
rshrc
|
|
914
942
|
else
|
|
915
943
|
puts "Unknown setting '#{setting}'"
|
|
916
|
-
puts "Available: history_dedup, session_autosave, completion_limit"
|
|
944
|
+
puts "Available: history_dedup, session_autosave, auto_correct, slow_command_threshold, completion_limit"
|
|
917
945
|
end
|
|
918
946
|
end
|
|
919
947
|
def env(*args) # Environment variable management
|
|
@@ -1024,6 +1052,12 @@ def rshrc # Write updates to .rshrc
|
|
|
1024
1052
|
conf += "@history_dedup = '#{@history_dedup}'\n" if @history_dedup && @history_dedup != 'smart'
|
|
1025
1053
|
conf.sub!(/^@session_autosave.*(\n|$)/, "")
|
|
1026
1054
|
conf += "@session_autosave = #{@session_autosave}\n" if @session_autosave && @session_autosave > 0
|
|
1055
|
+
conf.sub!(/^@auto_correct.*(\n|$)/, "")
|
|
1056
|
+
conf += "@auto_correct = #{@auto_correct}\n" if @auto_correct
|
|
1057
|
+
conf.sub!(/^@slow_command_threshold.*(\n|$)/, "")
|
|
1058
|
+
conf += "@slow_command_threshold = #{@slow_command_threshold}\n" if @slow_command_threshold && @slow_command_threshold > 0
|
|
1059
|
+
conf.sub!(/^@plugin_disabled.*(\n|$)/, "")
|
|
1060
|
+
conf += "@plugin_disabled = #{@plugin_disabled}\n" unless @plugin_disabled.empty?
|
|
1027
1061
|
# Only write @cmd_completions if user has customized it
|
|
1028
1062
|
unless conf =~ /^@cmd_completions\s*=/
|
|
1029
1063
|
# Don't write default completions to avoid cluttering .rshrc
|
|
@@ -1097,20 +1131,28 @@ def help
|
|
|
1097
1131
|
right_col << ":fg [id] Foreground job"
|
|
1098
1132
|
right_col << ":bg [id] Resume in bg"
|
|
1099
1133
|
right_col << ""
|
|
1134
|
+
right_col << "v3.2 NEW FEATURES:".c(@c_prompt).b
|
|
1135
|
+
right_col << ":plugins [cmd] Plugin system"
|
|
1136
|
+
right_col << ":stats --graph Visual bar charts"
|
|
1137
|
+
right_col << ":calc 2 + 2 Ruby calculator"
|
|
1138
|
+
right_col << "!!, !-2, !5:7 History repeat/chain"
|
|
1139
|
+
right_col << ":config auto_correct Auto-fix (w/confirm)"
|
|
1140
|
+
right_col << ":config slow_cmd... Slow alerts"
|
|
1141
|
+
right_col << ""
|
|
1142
|
+
right_col << "PLUGIN SYSTEM:".c(@c_prompt).b
|
|
1143
|
+
right_col << ":plugins List all"
|
|
1144
|
+
right_col << ":plugins reload Reload"
|
|
1145
|
+
right_col << ":plugins disable nm Disable"
|
|
1146
|
+
right_col << ":plugins info nm Details"
|
|
1147
|
+
right_col << ""
|
|
1100
1148
|
right_col << "v3.0/3.1 FEATURES:".c(@c_prompt).b
|
|
1101
|
-
right_col << ":stats
|
|
1102
|
-
right_col << ":
|
|
1103
|
-
right_col << "
|
|
1104
|
-
right_col << ":
|
|
1105
|
-
right_col << ":
|
|
1106
|
-
right_col << ":
|
|
1107
|
-
right_col << ":
|
|
1108
|
-
right_col << ":load_session [nm] Load session"
|
|
1109
|
-
right_col << ":list_sessions List all sessions"
|
|
1110
|
-
right_col << ":rmsession name|* Delete session(s)"
|
|
1111
|
-
right_col << ":theme [name] Color presets"
|
|
1112
|
-
right_col << ":config [set val] Settings"
|
|
1113
|
-
right_col << ":env [VARNAME] Env management"
|
|
1149
|
+
right_col << ":stats --clear Clear stats"
|
|
1150
|
+
right_col << ":stats --csv|--json Export stats"
|
|
1151
|
+
right_col << ":bm \"name\" Bookmarks"
|
|
1152
|
+
right_col << ":save_session [nm] Sessions"
|
|
1153
|
+
right_col << ":theme [name] Themes"
|
|
1154
|
+
right_col << ":config [set val] Config"
|
|
1155
|
+
right_col << ":env [VAR] Env vars"
|
|
1114
1156
|
right_col << ""
|
|
1115
1157
|
right_col << "INTEGRATIONS:".c(@c_prompt).b
|
|
1116
1158
|
right_col << "r Launch rtfm"
|
|
@@ -1331,6 +1373,16 @@ def stats(*args) # Show command execution statistics and analytics
|
|
|
1331
1373
|
fname = filename || "rsh_stats.csv"
|
|
1332
1374
|
export_stats_csv(fname)
|
|
1333
1375
|
return
|
|
1376
|
+
elsif format == "--graph"
|
|
1377
|
+
stats_graph
|
|
1378
|
+
return
|
|
1379
|
+
elsif format == "--clear"
|
|
1380
|
+
# Clear all statistics
|
|
1381
|
+
@cmd_frequency = {}
|
|
1382
|
+
@cmd_stats = {}
|
|
1383
|
+
puts "All statistics cleared"
|
|
1384
|
+
rshrc
|
|
1385
|
+
return
|
|
1334
1386
|
end
|
|
1335
1387
|
|
|
1336
1388
|
# Display stats (existing code)
|
|
@@ -1373,6 +1425,59 @@ def stats(*args) # Show command execution statistics and analytics
|
|
|
1373
1425
|
puts "\n Last command exit status: #{@last_exit == 0 ? 'Success'.c(@c_path) : "Failed (#{@last_exit})".c(196)}"
|
|
1374
1426
|
puts
|
|
1375
1427
|
end
|
|
1428
|
+
def stats_graph # Visual graph mode for stats
|
|
1429
|
+
puts "\n Command Usage Graph".c(@c_prompt).b
|
|
1430
|
+
puts " " + "="*50
|
|
1431
|
+
|
|
1432
|
+
return puts "No data to display" if @cmd_frequency.nil? || @cmd_frequency.empty?
|
|
1433
|
+
|
|
1434
|
+
sorted = @cmd_frequency.sort_by { |_, count| -count }.first(15)
|
|
1435
|
+
max_count = sorted.first[1]
|
|
1436
|
+
max_width = 40
|
|
1437
|
+
|
|
1438
|
+
puts
|
|
1439
|
+
sorted.each_with_index do |(cmd, count), i|
|
|
1440
|
+
# Calculate bar width (scaled to max_width)
|
|
1441
|
+
bar_width = (count.to_f / max_count * max_width).round
|
|
1442
|
+
bar = "█" * bar_width
|
|
1443
|
+
|
|
1444
|
+
# Color bars by intensity
|
|
1445
|
+
color = case bar_width
|
|
1446
|
+
when 0..10 then 244 # Gray
|
|
1447
|
+
when 11..20 then 3 # Yellow
|
|
1448
|
+
when 21..30 then 214 # Orange
|
|
1449
|
+
else 196 # Red
|
|
1450
|
+
end
|
|
1451
|
+
|
|
1452
|
+
puts " #{cmd.ljust(15)} #{count.to_s.rjust(4)}x #{bar.c(color)}"
|
|
1453
|
+
end
|
|
1454
|
+
|
|
1455
|
+
# Performance graph if data exists
|
|
1456
|
+
if @cmd_stats && !@cmd_stats.empty?
|
|
1457
|
+
puts "\n Command Performance Graph (avg time)".c(@c_prompt).b
|
|
1458
|
+
puts " " + "="*50
|
|
1459
|
+
puts
|
|
1460
|
+
|
|
1461
|
+
slowest = @cmd_stats.sort_by { |_, s| -(s[:avg_time] || 0) }.first(10)
|
|
1462
|
+
max_time = slowest.first[1][:avg_time]
|
|
1463
|
+
|
|
1464
|
+
slowest.each do |cmd, stats|
|
|
1465
|
+
bar_width = (stats[:avg_time] / max_time * max_width).round
|
|
1466
|
+
bar = "█" * bar_width
|
|
1467
|
+
|
|
1468
|
+
color = case bar_width
|
|
1469
|
+
when 0..10 then 2 # Green (fast)
|
|
1470
|
+
when 11..20 then 214 # Orange
|
|
1471
|
+
else 196 # Red (slow)
|
|
1472
|
+
end
|
|
1473
|
+
|
|
1474
|
+
time_str = "#{'%.3f' % stats[:avg_time]}s"
|
|
1475
|
+
puts " #{cmd.ljust(15)} #{time_str.rjust(8)} #{bar.c(color)}"
|
|
1476
|
+
end
|
|
1477
|
+
end
|
|
1478
|
+
|
|
1479
|
+
puts
|
|
1480
|
+
end
|
|
1376
1481
|
def export_stats(filename) # Export stats to file (JSON or CSV based on extension)
|
|
1377
1482
|
if filename.end_with?('.csv')
|
|
1378
1483
|
export_stats_csv(filename)
|
|
@@ -1744,6 +1849,155 @@ def theme(*args) # Apply color scheme presets
|
|
|
1744
1849
|
puts "Theme '#{name}' applied"
|
|
1745
1850
|
puts "Add this to .rshrc to make it permanent: :theme \"#{name}\""
|
|
1746
1851
|
end
|
|
1852
|
+
def load_plugins # Load all plugins from plugin directory
|
|
1853
|
+
return unless Dir.exist?(@plugin_dir)
|
|
1854
|
+
|
|
1855
|
+
plugin_files = Dir.glob(@plugin_dir + '/*.rb').sort
|
|
1856
|
+
|
|
1857
|
+
plugin_files.each do |plugin_file|
|
|
1858
|
+
plugin_name = File.basename(plugin_file, '.rb')
|
|
1859
|
+
|
|
1860
|
+
# Skip if disabled
|
|
1861
|
+
next if @plugin_disabled.include?(plugin_name)
|
|
1862
|
+
|
|
1863
|
+
begin
|
|
1864
|
+
# Load the plugin file
|
|
1865
|
+
load(plugin_file)
|
|
1866
|
+
|
|
1867
|
+
# Find the plugin class (conventionally PluginNamePlugin)
|
|
1868
|
+
class_name = plugin_name.split('_').map(&:capitalize).join + 'Plugin'
|
|
1869
|
+
|
|
1870
|
+
# Try to instantiate the plugin
|
|
1871
|
+
if Object.const_defined?(class_name)
|
|
1872
|
+
plugin_class = Object.const_get(class_name)
|
|
1873
|
+
rsh_context = {
|
|
1874
|
+
version: @version,
|
|
1875
|
+
history: @history,
|
|
1876
|
+
bookmarks: @bookmarks,
|
|
1877
|
+
nick: @nick,
|
|
1878
|
+
gnick: @gnick,
|
|
1879
|
+
pwd: Dir.pwd,
|
|
1880
|
+
config: method(:config),
|
|
1881
|
+
rsh: self
|
|
1882
|
+
}
|
|
1883
|
+
plugin_instance = plugin_class.new(rsh_context)
|
|
1884
|
+
@plugins << { name: plugin_name, instance: plugin_instance, class: class_name }
|
|
1885
|
+
|
|
1886
|
+
# Load plugin completions
|
|
1887
|
+
if plugin_instance.respond_to?(:add_completions)
|
|
1888
|
+
completions = plugin_instance.add_completions
|
|
1889
|
+
@cmd_completions.merge!(completions) if completions.is_a?(Hash)
|
|
1890
|
+
end
|
|
1891
|
+
|
|
1892
|
+
# Load plugin commands
|
|
1893
|
+
if plugin_instance.respond_to?(:add_commands)
|
|
1894
|
+
commands = plugin_instance.add_commands
|
|
1895
|
+
@plugin_commands.merge!(commands) if commands.is_a?(Hash)
|
|
1896
|
+
end
|
|
1897
|
+
|
|
1898
|
+
puts " Loaded plugin: #{plugin_name} (#{class_name})" if ENV['RSH_DEBUG']
|
|
1899
|
+
end
|
|
1900
|
+
rescue => e
|
|
1901
|
+
puts "Warning: Failed to load plugin '#{plugin_name}': #{e.message}" if ENV['RSH_DEBUG']
|
|
1902
|
+
puts " #{e.backtrace.first}" if ENV['RSH_DEBUG']
|
|
1903
|
+
end
|
|
1904
|
+
end
|
|
1905
|
+
|
|
1906
|
+
# Call on_startup for all plugins
|
|
1907
|
+
@plugins.each do |plugin|
|
|
1908
|
+
begin
|
|
1909
|
+
plugin[:instance].on_startup if plugin[:instance].respond_to?(:on_startup)
|
|
1910
|
+
rescue => e
|
|
1911
|
+
puts "Warning: Plugin '#{plugin[:name]}' on_startup failed: #{e.message}" if ENV['RSH_DEBUG']
|
|
1912
|
+
end
|
|
1913
|
+
end
|
|
1914
|
+
end
|
|
1915
|
+
def call_plugin_hook(hook_name, *args) # Call a lifecycle hook for all plugins
|
|
1916
|
+
results = []
|
|
1917
|
+
@plugins.each do |plugin|
|
|
1918
|
+
begin
|
|
1919
|
+
if plugin[:instance].respond_to?(hook_name)
|
|
1920
|
+
result = plugin[:instance].send(hook_name, *args)
|
|
1921
|
+
results << result unless result.nil?
|
|
1922
|
+
end
|
|
1923
|
+
rescue => e
|
|
1924
|
+
puts "Warning: Plugin '#{plugin[:name]}' hook '#{hook_name}' failed: #{e.message}" if ENV['RSH_DEBUG']
|
|
1925
|
+
end
|
|
1926
|
+
end
|
|
1927
|
+
results
|
|
1928
|
+
end
|
|
1929
|
+
def plugins(*args) # Plugin management command
|
|
1930
|
+
if args.empty?
|
|
1931
|
+
# List all plugins
|
|
1932
|
+
if @plugins.empty?
|
|
1933
|
+
puts "\nNo plugins loaded"
|
|
1934
|
+
puts "Place .rb files in #{@plugin_dir}"
|
|
1935
|
+
return
|
|
1936
|
+
end
|
|
1937
|
+
|
|
1938
|
+
puts "\n Loaded Plugins:".c(@c_prompt).b
|
|
1939
|
+
@plugins.each do |plugin|
|
|
1940
|
+
status = @plugin_disabled.include?(plugin[:name]) ? '[disabled]'.c(196) : '[enabled]'.c(@c_path)
|
|
1941
|
+
puts " #{plugin[:name].ljust(20)} #{status} (#{plugin[:class]})"
|
|
1942
|
+
end
|
|
1943
|
+
|
|
1944
|
+
unless @plugin_disabled.empty?
|
|
1945
|
+
puts "\n Disabled Plugins:".c(@c_stamp)
|
|
1946
|
+
@plugin_disabled.each { |name| puts " #{name}" }
|
|
1947
|
+
end
|
|
1948
|
+
puts
|
|
1949
|
+
elsif args[0] == 'reload'
|
|
1950
|
+
# Reload all plugins
|
|
1951
|
+
@plugins = []
|
|
1952
|
+
@plugin_commands = {}
|
|
1953
|
+
load_plugins
|
|
1954
|
+
puts "Plugins reloaded (#{@plugins.length} loaded)"
|
|
1955
|
+
elsif args[0] == 'enable' && args[1]
|
|
1956
|
+
# Enable a plugin
|
|
1957
|
+
plugin_name = args[1]
|
|
1958
|
+
@plugin_disabled.delete(plugin_name)
|
|
1959
|
+
puts "Plugin '#{plugin_name}' enabled. Use :plugins reload to load it"
|
|
1960
|
+
rshrc
|
|
1961
|
+
elsif args[0] == 'disable' && args[1]
|
|
1962
|
+
# Disable a plugin
|
|
1963
|
+
plugin_name = args[1]
|
|
1964
|
+
unless @plugin_disabled.include?(plugin_name)
|
|
1965
|
+
@plugin_disabled << plugin_name
|
|
1966
|
+
end
|
|
1967
|
+
@plugins.reject! { |p| p[:name] == plugin_name }
|
|
1968
|
+
puts "Plugin '#{plugin_name}' disabled"
|
|
1969
|
+
rshrc
|
|
1970
|
+
elsif args[0] == 'info' && args[1]
|
|
1971
|
+
# Show plugin info
|
|
1972
|
+
plugin_name = args[1]
|
|
1973
|
+
plugin = @plugins.find { |p| p[:name] == plugin_name }
|
|
1974
|
+
if plugin
|
|
1975
|
+
puts "\n Plugin: #{plugin_name}".c(@c_prompt).b
|
|
1976
|
+
puts " Class: #{plugin[:class]}"
|
|
1977
|
+
puts " File: #{@plugin_dir}/#{plugin_name}.rb"
|
|
1978
|
+
|
|
1979
|
+
puts "\n Hooks:".c(@c_nick)
|
|
1980
|
+
%i[on_startup on_command_before on_command_after on_prompt].each do |hook|
|
|
1981
|
+
has_hook = plugin[:instance].respond_to?(hook) ? '✓' : '✗'
|
|
1982
|
+
puts " #{has_hook} #{hook}"
|
|
1983
|
+
end
|
|
1984
|
+
|
|
1985
|
+
puts "\n Extensions:".c(@c_nick)
|
|
1986
|
+
puts " ✓ add_completions" if plugin[:instance].respond_to?(:add_completions)
|
|
1987
|
+
puts " ✓ add_commands" if plugin[:instance].respond_to?(:add_commands)
|
|
1988
|
+
puts
|
|
1989
|
+
else
|
|
1990
|
+
puts "Plugin '#{plugin_name}' not found"
|
|
1991
|
+
end
|
|
1992
|
+
else
|
|
1993
|
+
puts "Usage:"
|
|
1994
|
+
puts " :plugins List all plugins"
|
|
1995
|
+
puts " :plugins reload Reload all plugins"
|
|
1996
|
+
puts " :plugins enable NAME Enable a plugin"
|
|
1997
|
+
puts " :plugins disable NAME Disable a plugin"
|
|
1998
|
+
puts " :plugins info NAME Show plugin details"
|
|
1999
|
+
end
|
|
2000
|
+
end
|
|
1747
2001
|
def validate_command(cmd) # Syntax validation before execution
|
|
1748
2002
|
return nil if cmd.nil? || cmd.empty?
|
|
1749
2003
|
warnings = []
|
|
@@ -1759,19 +2013,75 @@ def validate_command(cmd) # Syntax validation before execution
|
|
|
1759
2013
|
warnings << "WARNING: Force flag without path" if cmd =~ /rm\s+-[rf]+\s*$/
|
|
1760
2014
|
warnings << "WARNING: Sudo with redirection" if cmd =~ /sudo.*>/
|
|
1761
2015
|
|
|
1762
|
-
# Check for common typos in popular commands
|
|
1763
|
-
if cmd =~ /^(\w+)/
|
|
2016
|
+
# Check for common typos in popular commands (skip for : commands)
|
|
2017
|
+
if cmd =~ /^(\w+)/ && cmd !~ /^:/
|
|
1764
2018
|
first_cmd = $1
|
|
1765
|
-
|
|
2019
|
+
# Check if command exists (don't warn for valid commands)
|
|
2020
|
+
is_valid_cmd = @exe.include?(first_cmd) ||
|
|
2021
|
+
@nick.include?(first_cmd) ||
|
|
2022
|
+
first_cmd == "cd" ||
|
|
2023
|
+
(@plugin_commands && @plugin_commands[first_cmd]) ||
|
|
2024
|
+
(@defuns && @defuns.keys.include?(first_cmd))
|
|
2025
|
+
|
|
2026
|
+
unless is_valid_cmd
|
|
1766
2027
|
suggestions = suggest_command(first_cmd)
|
|
1767
2028
|
if suggestions && !suggestions.empty?
|
|
1768
|
-
|
|
2029
|
+
# Auto-correct if enabled and first suggestion has distance ≤ 2
|
|
2030
|
+
if @auto_correct && levenshtein_distance(first_cmd, suggestions[0]) <= 2
|
|
2031
|
+
warnings << "AUTO-CORRECTING: '#{first_cmd}' → '#{suggestions[0]}'"
|
|
2032
|
+
else
|
|
2033
|
+
warnings << "Command '#{first_cmd}' not found. Did you mean: #{suggestions.join(', ')}?"
|
|
2034
|
+
end
|
|
1769
2035
|
end
|
|
1770
2036
|
end
|
|
1771
2037
|
end
|
|
1772
2038
|
|
|
1773
2039
|
warnings.empty? ? nil : warnings
|
|
1774
2040
|
end
|
|
2041
|
+
def calc(*args) # Inline calculator using Ruby's Math library
|
|
2042
|
+
if args.empty?
|
|
2043
|
+
puts "Usage: calc <expression>"
|
|
2044
|
+
puts "Examples:"
|
|
2045
|
+
puts " calc 2 + 2"
|
|
2046
|
+
puts " calc \"Math.sqrt(16)\""
|
|
2047
|
+
puts " calc \"Math::PI * 2\""
|
|
2048
|
+
return
|
|
2049
|
+
end
|
|
2050
|
+
|
|
2051
|
+
expression = args.join(' ')
|
|
2052
|
+
|
|
2053
|
+
begin
|
|
2054
|
+
# Safe evaluation with Math library
|
|
2055
|
+
result = eval(expression, binding, __FILE__, __LINE__)
|
|
2056
|
+
puts result
|
|
2057
|
+
rescue SyntaxError => e
|
|
2058
|
+
puts "Syntax error in expression: #{e.message}"
|
|
2059
|
+
rescue => e
|
|
2060
|
+
puts "Error evaluating expression: #{e.message}"
|
|
2061
|
+
end
|
|
2062
|
+
end
|
|
2063
|
+
def apply_auto_correct(cmd) # Apply auto-correction to command
|
|
2064
|
+
return cmd unless @auto_correct
|
|
2065
|
+
return cmd if cmd =~ /^:/ # Don't auto-correct colon commands
|
|
2066
|
+
return cmd unless cmd =~ /^(\w+)/
|
|
2067
|
+
|
|
2068
|
+
first_cmd = $1
|
|
2069
|
+
|
|
2070
|
+
# Don't auto-correct if command exists
|
|
2071
|
+
return cmd if @exe.include?(first_cmd)
|
|
2072
|
+
return cmd if @nick.include?(first_cmd)
|
|
2073
|
+
return cmd if first_cmd == "cd"
|
|
2074
|
+
return cmd if @plugin_commands && @plugin_commands[first_cmd]
|
|
2075
|
+
return cmd if @defuns && @defuns.keys.include?(first_cmd) # Check user defuns
|
|
2076
|
+
|
|
2077
|
+
suggestions = suggest_command(first_cmd)
|
|
2078
|
+
if suggestions && !suggestions.empty? && levenshtein_distance(first_cmd, suggestions[0]) <= 2
|
|
2079
|
+
# Auto-correct using first (closest) suggestion
|
|
2080
|
+
cmd.sub(/^#{first_cmd}/, suggestions[0])
|
|
2081
|
+
else
|
|
2082
|
+
cmd
|
|
2083
|
+
end
|
|
2084
|
+
end
|
|
1775
2085
|
def execute_conditional(cmd_line)
|
|
1776
2086
|
# Split on && and || while preserving the operators
|
|
1777
2087
|
parts = cmd_line.split(/(\s*&&\s*|\s*\|\|\s*)/)
|
|
@@ -2055,6 +2365,11 @@ def load_rshrc_safe
|
|
|
2055
2365
|
@defuns = {} unless @defuns.is_a?(Hash)
|
|
2056
2366
|
@history_dedup = 'smart' unless @history_dedup.is_a?(String)
|
|
2057
2367
|
@session_autosave = 0 unless @session_autosave.is_a?(Integer)
|
|
2368
|
+
@auto_correct = false unless [@auto_correct].any? { |v| v == true || v == false }
|
|
2369
|
+
@slow_command_threshold = 0 unless @slow_command_threshold.is_a?(Integer)
|
|
2370
|
+
@plugin_disabled = [] unless @plugin_disabled.is_a?(Array)
|
|
2371
|
+
@plugins = [] unless @plugins.is_a?(Array)
|
|
2372
|
+
@plugin_commands = {} unless @plugin_commands.is_a?(Hash)
|
|
2058
2373
|
|
|
2059
2374
|
# Restore defuns from .rshrc
|
|
2060
2375
|
if @defuns && !@defuns.empty?
|
|
@@ -2191,6 +2506,11 @@ def load_defaults
|
|
|
2191
2506
|
@switch_cache_time ||= {}
|
|
2192
2507
|
@history_dedup ||= 'smart'
|
|
2193
2508
|
@session_autosave ||= 0
|
|
2509
|
+
@auto_correct ||= false
|
|
2510
|
+
@slow_command_threshold ||= 0
|
|
2511
|
+
@plugin_disabled ||= []
|
|
2512
|
+
@plugins ||= []
|
|
2513
|
+
@plugin_commands ||= {}
|
|
2194
2514
|
puts "Loaded with default configuration."
|
|
2195
2515
|
end
|
|
2196
2516
|
|
|
@@ -2264,6 +2584,7 @@ begin # Load .rshrc and populate @history
|
|
|
2264
2584
|
@c.restore # Max row & col gotten, cursor restored
|
|
2265
2585
|
hist_clean # Remove duplicates, etc
|
|
2266
2586
|
@path.map! {|p| p + "/*"} # Set proper format for path search
|
|
2587
|
+
@plugins_loaded = false # Defer plugin loading until first command
|
|
2267
2588
|
end
|
|
2268
2589
|
|
|
2269
2590
|
# MAIN PART
|
|
@@ -2273,9 +2594,17 @@ loop do
|
|
|
2273
2594
|
@node = Etc.uname[:nodename] # For use in @prompt
|
|
2274
2595
|
h = @history; f = @cmd_frequency; s = @cmd_stats; b = @bookmarks; d = @defuns; load_rshrc_safe; @history = h; @cmd_frequency = f; @cmd_stats = s; @bookmarks = b; @defuns = d # reload prompt but preserve runtime data
|
|
2275
2596
|
@prompt.gsub!(/#{Dir.home}/, '~') # Simplify path in prompt
|
|
2276
|
-
system("printf \"\033]0;rsh: #{Dir.pwd}\007\"") # Set Window title to path
|
|
2597
|
+
system("printf \"\033]0;rsh: #{Dir.pwd}\007\"") # Set Window title to path
|
|
2277
2598
|
@history[0] = "" unless @history[0]
|
|
2278
2599
|
cache_executables # Use cached executable lookup
|
|
2600
|
+
# Load plugins on first command (lazy loading)
|
|
2601
|
+
unless @plugins_loaded
|
|
2602
|
+
load_plugins
|
|
2603
|
+
@plugins_loaded = true
|
|
2604
|
+
end
|
|
2605
|
+
# Append plugin prompt additions (after plugins loaded)
|
|
2606
|
+
plugin_prompts = call_plugin_hook(:on_prompt)
|
|
2607
|
+
@prompt += plugin_prompts.join if plugin_prompts.any?
|
|
2279
2608
|
# Auto-save session if enabled and interval elapsed
|
|
2280
2609
|
if @session_autosave && @session_autosave > 0
|
|
2281
2610
|
current_time = Time.now.to_i
|
|
@@ -2290,8 +2619,33 @@ loop do
|
|
|
2290
2619
|
@dirs.pop
|
|
2291
2620
|
hist_clean # Clean up the history
|
|
2292
2621
|
@cmd = "ls" if @cmd == "" # Default to ls when no command is given
|
|
2293
|
-
|
|
2294
|
-
|
|
2622
|
+
# Enhanced history commands
|
|
2623
|
+
if @cmd == '!!'
|
|
2624
|
+
# Repeat last command
|
|
2625
|
+
@cmd = @history[1] if @history.length > 1
|
|
2626
|
+
elsif @cmd =~ /^!-(\d+)$/
|
|
2627
|
+
# Repeat nth to last (e.g., !-2 = 2nd to last)
|
|
2628
|
+
index = $1.to_i
|
|
2629
|
+
@cmd = @history[index] if @history.length > index
|
|
2630
|
+
elsif @cmd =~ /^!(\d+):(\d+)$/
|
|
2631
|
+
# Chain commands (e.g., !5:7 = commands 5, 6, 7)
|
|
2632
|
+
start_idx = $1.to_i + 1
|
|
2633
|
+
end_idx = $2.to_i + 1
|
|
2634
|
+
if start_idx < @history.length && end_idx < @history.length && start_idx <= end_idx
|
|
2635
|
+
commands = @history[start_idx..end_idx].compact.reverse
|
|
2636
|
+
# Filter out colon commands (they don't work in shell chains)
|
|
2637
|
+
commands.reject! { |c| c =~ /^:/ }
|
|
2638
|
+
if commands.empty?
|
|
2639
|
+
puts "Cannot chain colon commands (use shell commands only)"
|
|
2640
|
+
@cmd = "ls" # Default to ls
|
|
2641
|
+
else
|
|
2642
|
+
@cmd = commands.join(' && ')
|
|
2643
|
+
puts " Chaining: #{@cmd}".c(@c_stamp)
|
|
2644
|
+
end
|
|
2645
|
+
end
|
|
2646
|
+
elsif @cmd.match(/^!\d+$/)
|
|
2647
|
+
# Original: !5 = command 5
|
|
2648
|
+
hi = @history[@cmd.sub(/^!(\d+)$/, '\1').to_i+1]
|
|
2295
2649
|
@cmd = hi if hi
|
|
2296
2650
|
end
|
|
2297
2651
|
# Move cursor to end of line and print the full command before clearing
|
|
@@ -2341,18 +2695,27 @@ loop do
|
|
|
2341
2695
|
elsif @cmd == '#' # List previous directories
|
|
2342
2696
|
dirs
|
|
2343
2697
|
else # Execute command
|
|
2344
|
-
# Check if it's a
|
|
2698
|
+
# Check if it's a plugin command FIRST
|
|
2345
2699
|
cmd_parts = @cmd.split(/\s+/)
|
|
2346
|
-
|
|
2347
|
-
if
|
|
2700
|
+
cmd_name = cmd_parts[0]
|
|
2701
|
+
if @plugin_commands && @plugin_commands[cmd_name]
|
|
2348
2702
|
begin
|
|
2349
2703
|
args = cmd_parts[1..]
|
|
2350
|
-
|
|
2351
|
-
result
|
|
2704
|
+
result = @plugin_commands[cmd_name].call(*args)
|
|
2705
|
+
puts result unless result.nil?
|
|
2706
|
+
rescue => e
|
|
2707
|
+
puts "Error in plugin command '#{cmd_name}': #{e}"
|
|
2708
|
+
end
|
|
2709
|
+
# Then check if it's a user-defined Ruby function (before any expansions)
|
|
2710
|
+
elsif self.respond_to?(cmd_name) && singleton_class.instance_methods(false).include?(cmd_name.to_sym)
|
|
2711
|
+
begin
|
|
2712
|
+
args = cmd_parts[1..]
|
|
2713
|
+
puts "DEBUG: Calling #{cmd_name} with args: #{args}" if ENV['RSH_DEBUG']
|
|
2714
|
+
result = self.send(cmd_name, *args)
|
|
2352
2715
|
puts "DEBUG: Result: #{result.inspect}" if ENV['RSH_DEBUG']
|
|
2353
2716
|
puts result unless result.nil?
|
|
2354
2717
|
rescue => e
|
|
2355
|
-
puts "Error calling function '#{
|
|
2718
|
+
puts "Error calling function '#{cmd_name}': #{e}"
|
|
2356
2719
|
end
|
|
2357
2720
|
else
|
|
2358
2721
|
# Handle conditional execution (&& and ||)
|
|
@@ -2414,10 +2777,27 @@ loop do
|
|
|
2414
2777
|
end
|
|
2415
2778
|
else
|
|
2416
2779
|
begin
|
|
2417
|
-
|
|
2780
|
+
pre_cmd
|
|
2781
|
+
|
|
2782
|
+
# Apply auto-correct if enabled (before validation)
|
|
2783
|
+
@cmd = apply_auto_correct(@cmd)
|
|
2784
|
+
|
|
2785
|
+
# Validate command after auto-correction
|
|
2418
2786
|
warnings = validate_command(@cmd)
|
|
2419
2787
|
if warnings && !warnings.empty?
|
|
2420
|
-
|
|
2788
|
+
# Show non-auto-correct warnings
|
|
2789
|
+
warnings.reject { |w| w.start_with?("AUTO-CORRECTING:") }.each { |w| puts "#{w}".c(196) }
|
|
2790
|
+
# Show auto-correct and ask for confirmation
|
|
2791
|
+
auto_correct_warnings = warnings.select { |w| w.start_with?("AUTO-CORRECTING:") }
|
|
2792
|
+
if auto_correct_warnings.any?
|
|
2793
|
+
auto_correct_warnings.each { |w| puts "#{w}".c(214) }
|
|
2794
|
+
print "Accept auto-correction? (Y/n): "
|
|
2795
|
+
response = $stdin.gets.chomp
|
|
2796
|
+
if response.downcase == 'n'
|
|
2797
|
+
puts "Auto-correction cancelled"
|
|
2798
|
+
next
|
|
2799
|
+
end
|
|
2800
|
+
end
|
|
2421
2801
|
# For critical warnings, ask for confirmation
|
|
2422
2802
|
if warnings.any? { |w| w.start_with?("WARNING:") }
|
|
2423
2803
|
print "Continue anyway? (y/N): "
|
|
@@ -2429,7 +2809,16 @@ loop do
|
|
|
2429
2809
|
end
|
|
2430
2810
|
end
|
|
2431
2811
|
|
|
2432
|
-
|
|
2812
|
+
# Call plugin on_command_before hooks
|
|
2813
|
+
plugin_results = call_plugin_hook(:on_command_before, @cmd)
|
|
2814
|
+
# If any plugin returns false, skip command
|
|
2815
|
+
if plugin_results.include?(false)
|
|
2816
|
+
puts "Command blocked by plugin"
|
|
2817
|
+
next
|
|
2818
|
+
end
|
|
2819
|
+
# If any plugin returns a modified command, use it
|
|
2820
|
+
modified_cmd = plugin_results.find { |r| r.is_a?(String) }
|
|
2821
|
+
@cmd = modified_cmd if modified_cmd
|
|
2433
2822
|
|
|
2434
2823
|
# Track command frequency for intelligent completion
|
|
2435
2824
|
cmd_base = @cmd.split.first if @cmd && !@cmd.empty?
|
|
@@ -2470,6 +2859,14 @@ loop do
|
|
|
2470
2859
|
@cmd_stats[cmd_base][:avg_time] = @cmd_stats[cmd_base][:total_time] / @cmd_stats[cmd_base][:count]
|
|
2471
2860
|
end
|
|
2472
2861
|
|
|
2862
|
+
# Slow command alert
|
|
2863
|
+
if @slow_command_threshold > 0 && elapsed > @slow_command_threshold
|
|
2864
|
+
puts "⚠ Command took #{'%.1f' % elapsed}s (threshold: #{@slow_command_threshold}s)".c(214)
|
|
2865
|
+
end
|
|
2866
|
+
|
|
2867
|
+
# Call plugin on_command_after hooks
|
|
2868
|
+
call_plugin_hook(:on_command_after, @cmd, @last_exit)
|
|
2869
|
+
|
|
2473
2870
|
post_cmd
|
|
2474
2871
|
rescue StandardError => err
|
|
2475
2872
|
puts "\nError: #{err}"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ruby-shell
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.
|
|
4
|
+
version: 3.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Geir Isene
|
|
@@ -12,9 +12,9 @@ date: 2025-10-22 00:00:00.000000000 Z
|
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: 'A shell written in Ruby with extensive tab completions, aliases/nicks,
|
|
14
14
|
history, syntax highlighting, theming, auto-cd, auto-opening files and more. UPDATE
|
|
15
|
-
v3.
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
v3.2.0: PLUGIN SYSTEM - Extensible architecture with lifecycle hooks (on_startup,
|
|
16
|
+
on_command_before/after, on_prompt), extension points (add_completions, add_commands),
|
|
17
|
+
plugin management, and 3 example plugins included. Plus colon command theming!'
|
|
18
18
|
email: g@isene.com
|
|
19
19
|
executables:
|
|
20
20
|
- rsh
|
|
@@ -22,6 +22,7 @@ extensions: []
|
|
|
22
22
|
extra_rdoc_files: []
|
|
23
23
|
files:
|
|
24
24
|
- ".rshrc"
|
|
25
|
+
- PLUGIN_GUIDE.md
|
|
25
26
|
- README.md
|
|
26
27
|
- bin/rsh
|
|
27
28
|
homepage: https://isene.com/
|