ruby-shell 2.12.0 → 3.0.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.
Files changed (4) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +24 -6
  3. data/bin/rsh +465 -68
  4. metadata +4 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8b242a4147fbf7ac84940b1e2bc7b292541e3d99a1a063a52efa899e737e1851
4
- data.tar.gz: 9c1e5298b951b939c3b85ab8831215079c43331a80805bbebab5c0cb3def5c4b
3
+ metadata.gz: 121b91f0e19b4e13cd6cacf00094552851ea44bf376c4e1f0e6554c83ee99a34
4
+ data.tar.gz: e63b51b9f7742b87b4bbc96ee41da6ba12ef93df0631b4ac564548b87f6d0a33
5
5
  SHA512:
6
- metadata.gz: dfa2498cfa54eb2b36170848c24f55026e45dbfa4926c4367efb866c45508e24beb52f841efbc08be376a8b8e4f9e0ec772c45bed479c48f6e8533107dd2b47a
7
- data.tar.gz: 899f55fc361186ac32ec1a3ccf62243003a3d500a04ac5c25138a767abbad47940116511dce00c58ec40bf97d37fc092a968f5a85ddaf1943afe307770fc3a38
6
+ metadata.gz: bc4fb4e0cc354ff353532a01f334ba85e23725cc5757328118ea85c0b25f46c814f12e9e63dfcf2ca83ef48f3c015696ffd9b8c6661b94398295a67c0be285dc
7
+ data.tar.gz: 1790d5590bcdec77d461dd491fc34fb915324e78eea7d385bbdd35e2f71f86eaa9ab3995fcbd3855c640dad029530baa7615994318fb9a42718aa7246ea938a9
data/README.md CHANGED
@@ -33,7 +33,18 @@ Or simply `gem install ruby-shell`.
33
33
  * All colors are themeable in .rshrc (see github link for possibilities)
34
34
  * Copy current command line to primary selection (paste w/middle button) with `Ctrl-y`
35
35
 
36
- ## NEW in v2.9.0 - AI Integration
36
+ ## NEW in v3.0.0 - Major Feature Release ⭐⭐⭐
37
+ * **Persistent Ruby Functions**: defun functions now save to .rshrc and persist across sessions
38
+ * **Smart Command Suggestions**: Typo detection with "Did you mean...?" suggestions using Levenshtein distance
39
+ * **Command Analytics**: New `:stats` command shows usage statistics, performance metrics, and most-used commands
40
+ * **Switch Completion Caching**: Command switches from --help are cached for instant completion
41
+ * **Enhanced Bookmarks**: Bookmark directories with tags - `:bookmark name path #tag1,tag2`
42
+ * **Session Management**: Save and restore entire shell sessions with `:save_session` and `:load_session`
43
+ * **Syntax Validation**: Pre-execution warnings for common mistakes, dangerous commands, and typos
44
+ * **Option Value Completion**: TAB completion for option values like `--format=<TAB>` → json, yaml, xml
45
+ * **Command Performance Tracking**: Automatically tracks execution time and shows slowest commands
46
+
47
+ ## AI Integration (v2.9.0) ⭐
37
48
  * **AI-powered command assistance**: Get help with commands using natural language
38
49
  * **`@ <question>`**: Ask questions and get AI-generated text responses
39
50
  * **`@@ <request>`**: Describe what you want to do, and AI suggests the command
@@ -73,13 +84,18 @@ Special functions/integrations:
73
84
  Special commands:
74
85
  * `:nick 'll = ls -l'` to make a command alias (ll) point to a command (ls -l)
75
86
  * `:gnick 'h = /home/me'` to make a general alias (h) point to something (/home/me)
76
- * `:nickdel 'name'` to delete a command nick (or use `:nick '-name'`)
77
- * `:gnickdel 'name'` to delete a general nick (or use `:gnick '-name'`)
78
- * `:nick?` will list all command nicks and general nicks (you can edit your nicks in .rshrc)
87
+ * `:nick` lists all command nicks, `:gnick` lists general nicks (NEW in v3.0)
88
+ * `:nick '-name'` delete a command nick, `:gnick '-name'` delete a general nick (NEW in v3.0)
79
89
  * `:history` will list the command history, while `:rmhistory` will delete the history
80
90
  * `:jobs` will list background jobs, `:fg [job_id]` brings jobs to foreground, `:bg [job_id]` resumes stopped jobs
81
- * `:defun 'func(args) = code'` defines Ruby functions callable as shell commands
91
+ * `:defun 'func(args) = code'` defines Ruby functions callable as shell commands (now persistent!)
82
92
  * `:defun?` lists all user-defined functions, `:defun '-func'` removes functions
93
+ * `:stats` shows command execution statistics and analytics (NEW in v3.0)
94
+ * `:bm "name"` or `:bookmark "name"` bookmark current directory, `:bm "name path #tags"` with tags (NEW in v3.0)
95
+ * `:bm` lists all bookmarks, just type bookmark name to jump (e.g., `work`) (NEW in v3.0)
96
+ * `:bm "-name"` delete bookmark, `:bm "?tag"` search by tag (NEW in v3.0)
97
+ * `:save_session` saves current shell session (pwd, history, bookmarks, defuns) (NEW in v3.0)
98
+ * `:load_session` restores previously saved session (NEW in v3.0)
83
99
  * `:info` shows introduction and feature overview
84
100
  * `:version` Shows the rsh version number and the last published gem file version
85
101
  * `:help` will display a compact command reference in two columns
@@ -185,7 +201,7 @@ Enter the command `f` to launch the fuzzy finder - select the directory/file you
185
201
  If you start a line with "=", the rest of the line will be interpreted as an XRPN program. This gives you the full power of XRPN right at your fingertips. You can do simple stuff like this: `=13,23,*,x^2` and the answer to `(13 * 23)^2` will be given (89401) in the format that you have set in your `.xrpn/conf`. Or you can do more elaborate stuff like `=fix 6,5,sto c,time,'Time now is: ',atime,aview,pse,fix 0,lbl a,rcl c,prx,dse c,gto a`. Go crazy. Use single-quotes for any Alpha entry.
186
202
 
187
203
  ## Syntax highlighting
188
- rsh will highlight nicks, gnicks, commands and dirs/files as they are written on the command line.
204
+ rsh will highlight nicks, gnicks, bookmarks, commands, switches and dirs/files as they are written on the command line. Each element type has its own color (customizable in .rshrc).
189
205
 
190
206
  ## Theming
191
207
  In the supplied `.rshrc`, you will find a set of colors that you can change:
@@ -197,6 +213,8 @@ Variable | Description
197
213
  `@c_nick` | Color for matching nick
198
214
  `@c_gnick` | Color for matching gnick
199
215
  `@c_path` | Color for valid path
216
+ `@c_switch` | Color for command switches/options
217
+ `@c_bookmark` | Color for bookmarks (NEW in v3.0)
200
218
  `@c_tabselect` | Color for selected tabcompleted item
201
219
  `@c_taboption` | Color for unselected tabcompleted item
202
220
  `@c_stamp` | Color for time stamp/command
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 = "2.12.0" # Extensible command completion system for git, apt, docker, systemctl, cargo, npm, gem, bundle
11
+ @version = "3.0.0" # Major release: Persistent defuns, switch caching, smart suggestions, analytics, tooltips, bookmarks, validation, sessions
12
12
 
13
13
  # MODULES, CLASSES AND EXTENSIONS
14
14
  class String # Add coloring to strings (with escaping for Readline)
@@ -92,6 +92,7 @@ begin # Initialization
92
92
  @c_gnick = 14 # Color for matching gnick
93
93
  @c_path = 3 # Color for valid path
94
94
  @c_switch = 6 # Color for switches/options
95
+ @c_bookmark = 13 # Color for bookmarks
95
96
  @c_tabselect = 5 # Color for selected tabcompleted item
96
97
  @c_taboption = 244 # Color for unselected tabcompleted item
97
98
  @c_stamp = 244 # Color for time stamp/command
@@ -131,6 +132,13 @@ begin # Initialization
131
132
  "gem" => %w[install uninstall update list search build push],
132
133
  "bundle" => %w[install update exec check config]
133
134
  }
135
+ # New v3.0 features initialization
136
+ @switch_cache = {} # Cache for command switches from --help
137
+ @switch_cache_time = {} # Timestamp for cache expiry
138
+ @bookmarks = {} # Enhanced bookmarks with tags
139
+ @defuns = {} # Store defun definitions for persistence
140
+ @cmd_stats = {} # Command execution statistics
141
+ @session_file = Dir.home + '/.rsh_session' # Session save file
134
142
  def pre_cmd; end # User-defined function to be run BEFORE command execution
135
143
  def post_cmd; end # User-defined function to be run AFTER command execution
136
144
  end
@@ -138,32 +146,37 @@ end
138
146
  # HELP TEXT
139
147
  @info = <<~INFO
140
148
 
141
- Hello #{@user}, welcome to rsh - the Ruby SHell.
142
-
143
- rsh does not attempt to compete with the grand old shells like bash and zsh.
144
- It serves the specific needs and wants of its author. If you like it, then feel free
149
+ Hello #{@user}, welcome to rsh v3.0 - the Ruby SHell.
150
+
151
+ rsh does not attempt to compete with the grand old shells like bash and zsh.
152
+ It serves the specific needs and wants of its author. If you like it, then feel free
145
153
  to ask for more or different features here: https://github.com/isene/rsh.
146
-
147
- Features:
154
+
155
+ Core Features:
148
156
  * Aliases (called nicks in rsh) - both for commands and general nicks
149
- * Syntax highlighting, matching nicks, system commands and valid dirs/files
157
+ * Syntax highlighting for nicks, bookmarks, commands, switches and valid dirs/files
150
158
  * Tab completions for nicks, system commands, command switches and dirs/files
151
159
  * Smart context-aware tab completion for git, apt, docker, systemctl, cargo, npm, gem, bundle
152
- * Tab completion presents matches in a list to pick from
153
- * When you start to write a command, rsh will suggest the first match in the history and
154
- present that in "toned down" letters - press the arrow right key to accept the suggestion
155
- * Writing a partial command and pressing `UP` will search history for matches.
156
- Go down/up in the list and press `TAB` or `ENTER` to accept, `Ctrl-g` or `Ctrl-c` to discard
157
- * History with editing, search and repeat a history command (with `!`)
158
- * Config file (.rshrc) updates on exit (with Ctrl-d) or not (with Ctrl-e)
159
- * Set of simple rsh specific commands like nick, nick?, history and rmhistory
160
- * rsh specific commands and full set of Ruby commands available via :<command>
161
- * All colors are themeable in .rshrc (see github link for possibilities)
162
- * Copy current command line to primary selection (paste w/middle button) with `Ctrl-y`
163
- * AI integration: Use @ for text responses and @@ for command suggestions (requires ollama or OpenAI)
164
-
165
- Use `:help` for command reference.
166
-
160
+ * History with editing, search and repeat (use `!` or UP arrow)
161
+ * Auto-suggestions from history (press RIGHT arrow to accept)
162
+ * Ruby functions callable as shell commands (persistent across sessions)
163
+ * AI integration: Use @ for text responses and @@ for command suggestions
164
+
165
+ NEW in v3.0:
166
+ * Command analytics - :stats shows usage patterns and performance metrics
167
+ * Enhanced bookmarks with tags - :bm "name path #tag1,tag2" then just type name to jump
168
+ * Session management - :save_session and :load_session preserve your entire shell state
169
+ * Smart typo detection - "Did you mean...?" suggestions for misspelled commands
170
+ * Switch caching - Faster TAB completion for command options
171
+ * Option value completion - TAB complete values like --format=json
172
+ * Syntax validation - Pre-execution warnings for dangerous or malformed commands
173
+ * Unified command syntax - :nick, :gnick, :bm all work the same way (list/create/delete)
174
+
175
+ Config file (.rshrc) updates on exit (Ctrl-d) or not (Ctrl-e).
176
+ All colors are themeable in .rshrc (see github link for possibilities).
177
+
178
+ Use `:help` for complete command reference.
179
+
167
180
  INFO
168
181
 
169
182
  @help = <<~HELP
@@ -192,6 +205,7 @@ def firstrun
192
205
  @c_gnick = 87 # Color for matching gnick
193
206
  @c_path = 208 # Color for valid path
194
207
  @c_switch = 148 # Color for switches/options
208
+ @c_bookmark = 13 # Color for bookmarks
195
209
  @c_tabselect = 207 # Color for selected tabcompleted item
196
210
  @c_taboption = 244 # Color for unselected tabcompleted item
197
211
  @c_stamp = 244 # Color for time stamp/command
@@ -468,7 +482,8 @@ def tab(type)
468
482
  @tabstr = @pretab if type == "hist" # Searching for matches with whole string in history
469
483
  @pretab = @pretab.delete_suffix(@tabstr)
470
484
  end
471
- type = "switch" if @tabstr && @tabstr[0] == "-"
485
+ type = "switch" if @tabstr && @tabstr[0] == "-" && !@tabstr.include?("=")
486
+ type = "option_value" if @tabstr && @tabstr =~ /^--?[\w-]+=/
472
487
  type = "env_vars" if @tabstr && @tabstr[0] == "$"
473
488
 
474
489
  # Debug output when RSH_DEBUG is set
@@ -517,12 +532,29 @@ def tab(type)
517
532
  @tabarray.shift # Take away @history[0]
518
533
  return if @tabarray.empty?
519
534
  when "switch"
520
- cmdswitch = @pretab.split(/[|, ]/).last.to_s
521
- hlp = `#{cmdswitch} --help 2>/dev/null`
522
- hlp = hlp.split("\n").grep(/^\s*-{1,2}[^-]/)
523
- hlp = hlp.map{|h| h.sub(/^\s*/, '').sub(/^--/, ' --')}
524
- hlp = hlp.reject{|h| /-</ =~ h}
525
- @tabarray = hlp
535
+ cmdswitch = @pretab.split(/[|, ]/).last.to_s.strip
536
+ @tabarray = get_command_switches(cmdswitch)
537
+ when "option_value" # Completion for option values like --format=<value>
538
+ if @tabstr =~ /^--?[\w-]+=(.*)/
539
+ value_prefix = $1
540
+ option = @tabstr.sub(/=.*/, '')
541
+ # Define common option value completions
542
+ value_completions = {
543
+ /format/ => %w[json yaml xml csv plain],
544
+ /output/ => %w[json yaml xml text html],
545
+ /level|log-level/ => %w[debug info warn error fatal],
546
+ /color/ => %w[auto always never],
547
+ /type/ => %w[file dir link all]
548
+ }
549
+ matches = []
550
+ value_completions.each do |pattern, values|
551
+ if option =~ pattern
552
+ matches = values.select { |v| v.start_with?(value_prefix) }
553
+ break unless matches.empty?
554
+ end
555
+ end
556
+ @tabarray = matches.map { |v| "#{option}=#{v}" }
557
+ end
526
558
  when "dirs_only" # Only show directories
527
559
  fdir = @tabstr + "*"
528
560
  dirs = Dir.glob(fdir).select { |d| Dir.exist?(d) }.map { |d| d + "/" }
@@ -717,6 +749,85 @@ def tabend
717
749
  @c_col = @pos0 + @pos
718
750
  @c.col(@c_col)
719
751
  end
752
+ def get_command_switches(command) # Helper function to extract switches from --help
753
+ # Check cache first (cache expires after 1 hour)
754
+ cache_key = command.to_s.strip
755
+ return [] if cache_key.empty?
756
+
757
+ current_time = Time.now.to_i
758
+ if @switch_cache[cache_key] && @switch_cache_time[cache_key] && (current_time - @switch_cache_time[cache_key]) < 3600
759
+ return @switch_cache[cache_key]
760
+ end
761
+
762
+ # Parse --help output
763
+ hlp = `#{command} --help 2>/dev/null`
764
+ # Try -h if --help didn't work
765
+ hlp = `#{command} -h 2>/dev/null` if hlp.empty?
766
+ return [] if hlp.empty?
767
+
768
+ switches = []
769
+
770
+ # Method 1: Lines starting with switches (traditional format)
771
+ switches = hlp.split("\n").grep(/^\s*-{1,2}[^-]/)
772
+ switches.map! { |h| h.sub(/^\s*/, '').sub(/^--/, ' --') }
773
+ switches.reject! { |h| /-</ =~ h }
774
+
775
+ # Method 2: Extract switches from usage line (git-style format)
776
+ if switches.empty?
777
+ usage_lines = hlp.split("\n").select { |l| l =~ /usage:|Usage:/ }
778
+ usage_lines.each do |line|
779
+ # Extract all switches from the usage line
780
+ line.scan(/(-[a-zA-Z]|--[a-z-]+)/).each do |match|
781
+ switch = match[0]
782
+ switches << (switch.start_with?('--') ? " #{switch}" : switch)
783
+ end
784
+ end
785
+ switches.uniq!
786
+ end
787
+
788
+ # Cache the result (even if empty, to avoid repeated failures)
789
+ @switch_cache[cache_key] = switches
790
+ @switch_cache_time[cache_key] = current_time
791
+
792
+ switches
793
+ end
794
+ def levenshtein_distance(s, t) # Calculate edit distance for smart suggestions
795
+ m = s.length
796
+ n = t.length
797
+ return m if n == 0
798
+ return n if m == 0
799
+ d = Array.new(m+1) {Array.new(n+1)}
800
+ (0..m).each {|i| d[i][0] = i}
801
+ (0..n).each {|j| d[0][j] = j}
802
+ (1..n).each do |j|
803
+ (1..m).each do |i|
804
+ d[i][j] = if s[i-1] == t[j-1]
805
+ d[i-1][j-1]
806
+ else
807
+ [d[i-1][j]+1, d[i][j-1]+1, d[i-1][j-1]+1].min
808
+ end
809
+ end
810
+ end
811
+ d[m][n]
812
+ end
813
+ def suggest_command(cmd) # Smart command suggestions for typos
814
+ return nil if cmd.nil? || cmd.empty?
815
+ return nil if @exe.include?(cmd) || @nick.include?(cmd)
816
+
817
+ # Find commands with small edit distance
818
+ candidates = (@exe + @nick.keys).select do |c|
819
+ next false if c.length < 2
820
+ dist = levenshtein_distance(cmd, c)
821
+ max_dist = [cmd.length / 3, 2].max
822
+ dist <= max_dist && dist > 0
823
+ end
824
+
825
+ return nil if candidates.empty?
826
+
827
+ # Sort by distance
828
+ candidates.sort_by! { |c| levenshtein_distance(cmd, c) }
829
+ candidates.first(3)
830
+ end
720
831
  def hist_clean # Clean up @history
721
832
  @history.uniq!
722
833
  @history.compact!
@@ -734,6 +845,7 @@ def cmd_check(str) # Check if each element on the readline matches commands, nic
734
845
 
735
846
  str.gsub(/(?:\S'[^']*'|[^ '])+/) do |el|
736
847
  clean_el = el.gsub("'", "")
848
+ # Priority: commands > nicks > bookmarks > paths
737
849
  if @exe.include?(el)
738
850
  el.c(@c_cmd)
739
851
  elsif el == "cd"
@@ -741,8 +853,6 @@ def cmd_check(str) # Check if each element on the readline matches commands, nic
741
853
  elsif clean_el =~ /^\.\/(.+)/ && File.exist?(clean_el) && File.executable?(clean_el)
742
854
  # Color local executables starting with ./
743
855
  el.c(@c_cmd)
744
- elsif File.exist?(clean_el)
745
- el.c(@c_path)
746
856
  elsif @nick.include?(el)
747
857
  el.c(@c_nick)
748
858
  elsif el == "r" or el == "f"
@@ -751,6 +861,11 @@ def cmd_check(str) # Check if each element on the readline matches commands, nic
751
861
  el.c(@c_gnick)
752
862
  elsif self.respond_to?(el) && singleton_class.instance_methods(false).include?(el.to_sym)
753
863
  el.c(@c_nick).b # Ruby functions in bold nick color
864
+ elsif @bookmarks && @bookmarks.include?(el)
865
+ # Color bookmarks (after commands and nicks)
866
+ el.c(@c_bookmark)
867
+ elsif File.exist?(clean_el)
868
+ el.c(@c_path)
754
869
  elsif el[0] == "-"
755
870
  el.c(@c_switch)
756
871
  else
@@ -771,6 +886,12 @@ def rshrc # Write updates to .rshrc
771
886
  conf += "@gnick = #{@gnick}\n"
772
887
  conf.sub!(/^@cmd_frequency.*(\n|$)/, "")
773
888
  conf += "@cmd_frequency = #{@cmd_frequency}\n"
889
+ conf.sub!(/^@cmd_stats.*(\n|$)/, "")
890
+ conf += "@cmd_stats = #{@cmd_stats}\n" unless @cmd_stats.empty?
891
+ conf.sub!(/^@bookmarks.*(\n|$)/, "")
892
+ conf += "@bookmarks = #{@bookmarks}\n" unless @bookmarks.empty?
893
+ conf.sub!(/^@defuns.*(\n|$)/, "")
894
+ conf += "@defuns = #{@defuns}\n" unless @defuns.empty?
774
895
  # Only write @cmd_completions if user has customized it
775
896
  unless conf =~ /^@cmd_completions\s*=/
776
897
  # Don't write default completions to avoid cluttering .rshrc
@@ -820,9 +941,10 @@ def help
820
941
  left_col << "SPECIAL COMMANDS:".c(@c_prompt).b
821
942
  left_col << ":nick 'll = ls -l' Command alias"
822
943
  left_col << ":gnick 'h = /home' General alias"
823
- left_col << ":nickdel 'name' Delete nick"
824
- left_col << ":gnickdel 'name' Delete gnick"
825
- left_col << ":nick? List all nicks"
944
+ left_col << ":nick List nicks"
945
+ left_col << ":gnick List gnicks"
946
+ left_col << ":nick '-name' Delete nick"
947
+ left_col << ":gnick '-name' Delete gnick"
826
948
  left_col << ":history Show history"
827
949
  left_col << ":rmhistory Clear history"
828
950
  left_col << ":info About rsh"
@@ -842,6 +964,16 @@ def help
842
964
  right_col << ":fg [id] Foreground job"
843
965
  right_col << ":bg [id] Resume in bg"
844
966
  right_col << ""
967
+ right_col << "v3.0 NEW FEATURES:".c(@c_prompt).b
968
+ right_col << ":stats Command analytics"
969
+ right_col << ":bm \"name\" Create bookmark"
970
+ right_col << "name Jump to bookmark"
971
+ right_col << ":bm List bookmarks"
972
+ right_col << ":bm \"-name\" Delete bookmark"
973
+ right_col << ":bm \"?tag\" Search by tag"
974
+ right_col << ":save_session Save session"
975
+ right_col << ":load_session Restore session"
976
+ right_col << ""
845
977
  right_col << "INTEGRATIONS:".c(@c_prompt).b
846
978
  right_col << "r Launch rtfm"
847
979
  right_col << "f Launch fzf"
@@ -855,8 +987,8 @@ def help
855
987
  right_col << "SMART COMPLETIONS:".c(@c_prompt).b
856
988
  right_col << "git <TAB> Git subcommands"
857
989
  right_col << "apt/docker <TAB> Command options"
858
- right_col << "Supports: git, apt, docker,"
859
- right_col << "systemctl, cargo, npm, gem"
990
+ right_col << "--format=<TAB> Option values"
991
+ right_col << "Typo suggestions Auto-correct"
860
992
  right_col << ""
861
993
  right_col << "EXPANSIONS:".c(@c_prompt).b
862
994
  right_col << "~ Home directory"
@@ -899,43 +1031,47 @@ def rmhistory # Delete history
899
1031
  @history = []
900
1032
  puts "History deleted."
901
1033
  end
902
- def nick(nick_str) # Define a new nick like this: `:nick "ls = ls --color"`
903
- if nick_str.match(/^\s*-/)
1034
+ def nick(nick_str = nil) # Define a new nick like this: `:nick "ls = ls --color"`
1035
+ if nick_str.nil? || nick_str.empty?
1036
+ # List all nicks
1037
+ puts "\n Command nicks:".c(@c_nick).b
1038
+ if @nick.empty?
1039
+ puts " (none defined)"
1040
+ else
1041
+ @nick.sort.each {|key, value| puts " #{key.c(@c_nick)} = #{value}"}
1042
+ end
1043
+ puts
1044
+ elsif nick_str.match(/^\s*-/)
904
1045
  source = nick_str.sub(/^\s*-/, '')
905
1046
  @nick.delete(source)
1047
+ rshrc
906
1048
  else
907
1049
  source = nick_str.sub(/ =.*/, '')
908
1050
  target = nick_str.sub(/.*= /, '')
909
1051
  @nick[source] = target
1052
+ rshrc
910
1053
  end
911
- rshrc
912
1054
  end
913
- def gnick(nick_str) # Define a generic/global nick to match not only commands (format like nick)
914
- if nick_str.match(/^\s*-/)
1055
+ def gnick(nick_str = nil) # Define a generic/global nick to match not only commands (format like nick)
1056
+ if nick_str.nil? || nick_str.empty?
1057
+ # List all gnicks
1058
+ puts "\n General nicks:".c(@c_gnick).b
1059
+ if @gnick.empty?
1060
+ puts " (none defined)"
1061
+ else
1062
+ @gnick.sort.each {|key, value| puts " #{key.c(@c_gnick)} = #{value}"}
1063
+ end
1064
+ puts
1065
+ elsif nick_str.match(/^\s*-/)
915
1066
  source = nick_str.sub(/^\s*-/, '')
916
1067
  @gnick.delete(source)
1068
+ rshrc
917
1069
  else
918
1070
  source = nick_str.sub(/ =.*/, '')
919
1071
  target = nick_str.sub(/.*= /, '')
920
1072
  @gnick[source] = target
1073
+ rshrc
921
1074
  end
922
- rshrc
923
- end
924
- def nick? # Show nicks
925
- puts " Command nicks:".c(@c_nick)
926
- @nick.sort.each {|key, value| puts " #{key} = #{value}"}
927
- puts " General nicks:".c(@c_gnick)
928
- @gnick.sort.each {|key, value| puts " #{key} = #{value}"}
929
- end
930
- def nickdel(nick_name) # Delete a command nick
931
- @nick.delete(nick_name)
932
- rshrc
933
- puts "Nick '#{nick_name}' deleted"
934
- end
935
- def gnickdel(nick_name) # Delete a general/global nick
936
- @gnick.delete(nick_name)
937
- rshrc
938
- puts "General nick '#{nick_name}' deleted"
939
1075
  end
940
1076
  def dirs
941
1077
  puts "Past direactories:"
@@ -994,6 +1130,7 @@ def defun(func_def) # Define a Ruby function like: `:defun "myls(*args) = Dir.g
994
1130
  func_name = func_def.sub(/^\s*-/, '')
995
1131
  if self.respond_to?(func_name)
996
1132
  singleton_class.remove_method(func_name.to_sym)
1133
+ @defuns.delete(func_name)
997
1134
  puts "Function '#{func_name}' removed"
998
1135
  else
999
1136
  puts "Function '#{func_name}' not found"
@@ -1005,11 +1142,12 @@ def defun(func_def) # Define a Ruby function like: `:defun "myls(*args) = Dir.g
1005
1142
  func_name = $1
1006
1143
  func_params = $2
1007
1144
  func_body = $3
1008
-
1145
+
1009
1146
  begin
1010
1147
  eval_code = "def #{func_name}(#{func_params}); #{func_body}; end"
1011
1148
  puts " DEBUG: Evaluating: #{eval_code}" if ENV['RSH_DEBUG']
1012
1149
  singleton_class.class_eval(eval_code)
1150
+ @defuns[func_name] = func_def # Store for persistence
1013
1151
  puts "Function '#{func_name}' defined"
1014
1152
  puts " DEBUG: Method created? #{respond_to?(func_name)}" if ENV['RSH_DEBUG']
1015
1153
  rescue SyntaxError => e
@@ -1038,6 +1176,202 @@ def defun? # Show all user-defined functions
1038
1176
  end
1039
1177
  end
1040
1178
  end
1179
+ def stats # Show command execution statistics and analytics
1180
+ puts "\n Command Execution Statistics".c(@c_prompt).b
1181
+ puts " " + "="*50
1182
+
1183
+ # Most used commands
1184
+ if @cmd_frequency && !@cmd_frequency.empty?
1185
+ puts "\n Top 10 Most Used Commands:".c(@c_nick)
1186
+ sorted = @cmd_frequency.sort_by { |_, count| -count }.first(10)
1187
+ sorted.each_with_index do |(cmd, count), i|
1188
+ bar = "■" * ([count / 5, 20].min)
1189
+ puts " #{(i+1).to_s.rjust(2)}. #{cmd.ljust(20)} #{count.to_s.rjust(5)}x #{bar.c(@c_path)}"
1190
+ end
1191
+ end
1192
+
1193
+ # Command statistics from @cmd_stats
1194
+ if @cmd_stats && !@cmd_stats.empty?
1195
+ total_time = @cmd_stats.values.map { |s| s[:total_time] || 0 }.sum
1196
+ total_cmds = @cmd_stats.values.map { |s| s[:count] || 0 }.sum
1197
+
1198
+ puts "\n Performance Statistics:".c(@c_nick)
1199
+ puts " Total commands executed: #{total_cmds}"
1200
+ puts " Total execution time: #{'%.2f' % total_time}s"
1201
+ puts " Average time per command: #{'%.2f' % (total_time / total_cmds)}s" if total_cmds > 0
1202
+
1203
+ puts "\n Slowest Commands:".c(@c_nick)
1204
+ slowest = @cmd_stats.sort_by { |_, s| -(s[:avg_time] || 0) }.first(5)
1205
+ slowest.each_with_index do |(cmd, stats), i|
1206
+ puts " #{(i+1).to_s.rjust(2)}. #{cmd.ljust(20)} avg: #{'%.3f' % (stats[:avg_time] || 0)}s"
1207
+ end
1208
+ end
1209
+
1210
+ # History statistics
1211
+ puts "\n History Statistics:".c(@c_nick)
1212
+ puts " Total history entries: #{@history.length}"
1213
+ puts " Unique commands: #{@history.uniq.length}"
1214
+
1215
+ # Success/failure tracking
1216
+ puts "\n Last command exit status: #{@last_exit == 0 ? 'Success'.c(@c_path) : "Failed (#{@last_exit})".c(196)}"
1217
+ puts
1218
+ end
1219
+ def bm(args = nil) # Enhanced bookmark management with tags
1220
+ if args.nil? || args.empty?
1221
+ # List all bookmarks
1222
+ if @bookmarks.empty?
1223
+ puts "No bookmarks defined. Use :bookmark <name> to bookmark current directory"
1224
+ return
1225
+ end
1226
+ puts "\n Bookmarks:".c(@c_prompt).b
1227
+ @bookmarks.each do |name, data|
1228
+ path = data.is_a?(Hash) ? data[:path] : data
1229
+ tags = data.is_a?(Hash) && data[:tags] ? " [#{data[:tags].join(', ')}]" : ""
1230
+ puts " #{name.c(@c_nick)} → #{path}#{tags.c(@c_stamp)}"
1231
+ end
1232
+ puts
1233
+ elsif args =~ /^(\w+)\s+(.+)$/
1234
+ # Set bookmark with optional tags
1235
+ name, rest = $1, $2
1236
+ if rest.include?('#')
1237
+ path_part, tag_part = rest.split('#', 2)
1238
+ path = path_part.strip
1239
+ path = Dir.pwd if path.empty?
1240
+ tags = tag_part.split(',').map(&:strip)
1241
+ @bookmarks[name] = {path: path, tags: tags}
1242
+ else
1243
+ @bookmarks[name] = {path: rest.strip, tags: []}
1244
+ end
1245
+ puts "Bookmark '#{name}' set to #{@bookmarks[name][:path]}"
1246
+ rshrc
1247
+ elsif args =~ /^-(\w+)$/
1248
+ # Delete bookmark
1249
+ name = $1
1250
+ if @bookmarks.delete(name)
1251
+ puts "Bookmark '#{name}' deleted"
1252
+ rshrc
1253
+ else
1254
+ puts "Bookmark '#{name}' not found"
1255
+ end
1256
+ elsif args =~ /^\?(\w*)$/
1257
+ # Search bookmarks by tag
1258
+ tag = $1
1259
+ if tag.empty?
1260
+ puts "Available tags:"
1261
+ all_tags = @bookmarks.values.flat_map { |d| d.is_a?(Hash) ? d[:tags] : [] }.uniq.sort
1262
+ puts " " + all_tags.join(", ")
1263
+ else
1264
+ matches = @bookmarks.select do |_, data|
1265
+ data.is_a?(Hash) && data[:tags] && data[:tags].include?(tag)
1266
+ end
1267
+ if matches.empty?
1268
+ puts "No bookmarks with tag '#{tag}'"
1269
+ else
1270
+ puts "Bookmarks with tag '#{tag}':"
1271
+ matches.each { |name, data| puts " #{name} → #{data[:path]}" }
1272
+ end
1273
+ end
1274
+ else
1275
+ # Bookmark current directory
1276
+ name = args.strip
1277
+ @bookmarks[name] = {path: Dir.pwd, tags: []}
1278
+ puts "Bookmark '#{name}' set to #{Dir.pwd}"
1279
+ rshrc
1280
+ end
1281
+ end
1282
+ def bookmark(args = nil) # Alias for bm
1283
+ bm(args)
1284
+ end
1285
+ def save_session # Save current session state
1286
+ session = {
1287
+ pwd: Dir.pwd,
1288
+ history: @history.first(50),
1289
+ bookmarks: @bookmarks,
1290
+ defuns: @defuns,
1291
+ timestamp: Time.now.to_i
1292
+ }
1293
+ begin
1294
+ require 'json'
1295
+ File.write(@session_file, JSON.pretty_generate(session))
1296
+ puts "Session saved to #{@session_file}"
1297
+ rescue => e
1298
+ puts "Error saving session: #{e.message}"
1299
+ end
1300
+ end
1301
+ def load_session # Restore previous session
1302
+ unless File.exist?(@session_file)
1303
+ puts "No saved session found"
1304
+ return
1305
+ end
1306
+ begin
1307
+ require 'json'
1308
+ session = JSON.parse(File.read(@session_file), symbolize_names: true)
1309
+
1310
+ # Restore state
1311
+ Dir.chdir(session[:pwd]) if session[:pwd] && Dir.exist?(session[:pwd])
1312
+
1313
+ # Merge history (prepend saved history)
1314
+ if session[:history]
1315
+ @history = (session[:history] + @history).uniq.first(@histsize)
1316
+ end
1317
+
1318
+ # Restore bookmarks
1319
+ if session[:bookmarks]
1320
+ session[:bookmarks].each do |name, data|
1321
+ bookmark_data = data.is_a?(Hash) ? data.transform_keys(&:to_sym) : data
1322
+ @bookmarks[name.to_s] = bookmark_data
1323
+ end
1324
+ end
1325
+
1326
+ # Restore defuns
1327
+ if session[:defuns]
1328
+ session[:defuns].each do |name, func_def|
1329
+ next unless func_def.is_a?(String)
1330
+ @defuns[name.to_s] = func_def
1331
+ # Re-evaluate the function
1332
+ if func_def =~ /^(\w+)\s*\(([^)]*)\)\s*=\s*(.+)$/
1333
+ func_name, func_params, func_body = $1, $2, $3
1334
+ eval_code = "def #{func_name}(#{func_params}); #{func_body}; end"
1335
+ singleton_class.class_eval(eval_code) rescue nil
1336
+ end
1337
+ end
1338
+ end
1339
+
1340
+ saved_time = Time.at(session[:timestamp] || 0).strftime("%Y-%m-%d %H:%M:%S")
1341
+ puts "Session restored from #{saved_time}"
1342
+ rshrc
1343
+ rescue => e
1344
+ puts "Error loading session: #{e.message}"
1345
+ end
1346
+ end
1347
+ def validate_command(cmd) # Syntax validation before execution
1348
+ return nil if cmd.nil? || cmd.empty?
1349
+ warnings = []
1350
+
1351
+ # Check for common mistakes
1352
+ warnings << "Unmatched quotes" if cmd.count("'").odd? || cmd.count('"').odd?
1353
+ warnings << "Unmatched parentheses" if cmd.count("(") != cmd.count(")")
1354
+ warnings << "Unmatched brackets" if cmd.count("[") != cmd.count("]")
1355
+ warnings << "Unmatched braces" if cmd.count("{") != cmd.count("}")
1356
+
1357
+ # Check for potentially dangerous patterns
1358
+ warnings << "WARNING: Recursive rm detected" if cmd =~ /rm\s+.*-r.*\//
1359
+ warnings << "WARNING: Force flag without path" if cmd =~ /rm\s+-[rf]+\s*$/
1360
+ warnings << "WARNING: Sudo with redirection" if cmd =~ /sudo.*>/
1361
+
1362
+ # Check for common typos in popular commands
1363
+ if cmd =~ /^(\w+)/
1364
+ first_cmd = $1
1365
+ unless @exe.include?(first_cmd) || @nick.include?(first_cmd) || first_cmd == "cd"
1366
+ suggestions = suggest_command(first_cmd)
1367
+ if suggestions && !suggestions.empty?
1368
+ warnings << "Command '#{first_cmd}' not found. Did you mean: #{suggestions.join(', ')}?"
1369
+ end
1370
+ end
1371
+ end
1372
+
1373
+ warnings.empty? ? nil : warnings
1374
+ end
1041
1375
  def execute_conditional(cmd_line)
1042
1376
  # Split on && and || while preserving the operators
1043
1377
  parts = cmd_line.split(/(\s*&&\s*|\s*\|\|\s*)/)
@@ -1316,6 +1650,25 @@ def load_rshrc_safe
1316
1650
  @nick = {} unless @nick.is_a?(Hash)
1317
1651
  @gnick = {} unless @gnick.is_a?(Hash)
1318
1652
  @cmd_frequency = {} unless @cmd_frequency.is_a?(Hash)
1653
+ @cmd_stats = {} unless @cmd_stats.is_a?(Hash)
1654
+ @bookmarks = {} unless @bookmarks.is_a?(Hash)
1655
+ @defuns = {} unless @defuns.is_a?(Hash)
1656
+
1657
+ # Restore defuns from .rshrc
1658
+ if @defuns && !@defuns.empty?
1659
+ @defuns.each do |name, func_def|
1660
+ next unless func_def.is_a?(String)
1661
+ if func_def =~ /^(\w+)\s*\(([^)]*)\)\s*=\s*(.+)$/
1662
+ func_name, func_params, func_body = $1, $2, $3
1663
+ begin
1664
+ eval_code = "def #{func_name}(#{func_params}); #{func_body}; end"
1665
+ singleton_class.class_eval(eval_code)
1666
+ rescue => e
1667
+ puts "Warning: Could not load defun '#{name}': #{e.message}" if ENV['RSH_DEBUG']
1668
+ end
1669
+ end
1670
+ end
1671
+ end
1319
1672
 
1320
1673
  rescue SyntaxError => e
1321
1674
  puts "\n\033[31mERROR: Syntax error in .rshrc:\033[0m"
@@ -1429,6 +1782,11 @@ def load_defaults
1429
1782
  @completion_show_descriptions ||= false
1430
1783
  @completion_fuzzy ||= true
1431
1784
  @cmd_frequency ||= {}
1785
+ @cmd_stats ||= {}
1786
+ @bookmarks ||= {}
1787
+ @defuns ||= {}
1788
+ @switch_cache ||= {}
1789
+ @switch_cache_time ||= {}
1432
1790
  puts "Loaded with default configuration."
1433
1791
  end
1434
1792
 
@@ -1509,7 +1867,7 @@ loop do
1509
1867
  begin
1510
1868
  @user = Etc.getpwuid(Process.euid).name # For use in @prompt
1511
1869
  @node = Etc.uname[:nodename] # For use in @prompt
1512
- h = @history; load_rshrc_safe; @history = h # reload prompt but not history safely
1870
+ 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
1513
1871
  @prompt.gsub!(/#{Dir.home}/, '~') # Simplify path in prompt
1514
1872
  system("printf \"\033]0;rsh: #{Dir.pwd}\007\"") # Set Window title to path
1515
1873
  @history[0] = "" unless @history[0]
@@ -1609,11 +1967,22 @@ loop do
1609
1967
  @cmd = Dir.home if @cmd == "~"
1610
1968
  @cmd = @dirs[1] if @cmd == "-"
1611
1969
  @cmd = @dirs[@cmd.to_i] if @cmd =~ /^\d$/
1612
- # Check if it's a directory to change to
1970
+ # Check if it's a directory to change to first
1613
1971
  dir = @cmd.strip.sub(/~/, Dir.home)
1614
1972
  if Dir.exist?(dir)
1615
- Dir.chdir(dir)
1973
+ Dir.chdir(dir)
1616
1974
  system("git status .") if Dir.exist?(".git")
1975
+ # Then check if it's a bookmark (commands and nicks already handled above)
1976
+ elsif @bookmarks && @bookmarks[@cmd]
1977
+ bookmark_data = @bookmarks[@cmd]
1978
+ bm_dir = bookmark_data.is_a?(Hash) ? bookmark_data[:path] : bookmark_data
1979
+ if Dir.exist?(bm_dir)
1980
+ Dir.chdir(bm_dir)
1981
+ puts "Jumped to bookmark '#{@cmd}' → #{bm_dir}".c(@c_path)
1982
+ system("git status .") if Dir.exist?(".git")
1983
+ else
1984
+ puts "Bookmark '#{@cmd}' points to non-existent directory: #{bm_dir}".c(196)
1985
+ end
1617
1986
  else
1618
1987
  puts "#{Time.now.strftime("%H:%M:%S")}: #{@cmd}".c(@c_stamp)
1619
1988
  if @cmd == "f" # fzf integration (https://github.com/junegunn/fzf)
@@ -1630,16 +1999,34 @@ loop do
1630
1999
  Thread.new { system("xdg-open #{@cmd} 2>/dev/null") }
1631
2000
  end
1632
2001
  end
1633
- else
2002
+ else
1634
2003
  begin
2004
+ # Validate command before execution
2005
+ warnings = validate_command(@cmd)
2006
+ if warnings && !warnings.empty?
2007
+ warnings.each { |w| puts "#{w}".c(196) }
2008
+ # For critical warnings, ask for confirmation
2009
+ if warnings.any? { |w| w.start_with?("WARNING:") }
2010
+ print "Continue anyway? (y/N): "
2011
+ response = $stdin.gets.chomp
2012
+ unless response.downcase == 'y'
2013
+ puts "Command cancelled"
2014
+ next
2015
+ end
2016
+ end
2017
+ end
2018
+
1635
2019
  pre_cmd
1636
2020
 
1637
2021
  # Track command frequency for intelligent completion
1638
- if @cmd && !@cmd.empty?
1639
- cmd_base = @cmd.split.first
1640
- @cmd_frequency[cmd_base] = (@cmd_frequency[cmd_base] || 0) + 1 if cmd_base
2022
+ cmd_base = @cmd.split.first if @cmd && !@cmd.empty?
2023
+ if cmd_base
2024
+ @cmd_frequency[cmd_base] = (@cmd_frequency[cmd_base] || 0) + 1
1641
2025
  end
1642
2026
 
2027
+ # Start timing
2028
+ start_time = Time.now
2029
+
1643
2030
  # Handle background jobs
1644
2031
  if @cmd.end_with?(' &')
1645
2032
  @cmd = @cmd[0..-3] # Remove the &
@@ -1653,13 +2040,23 @@ loop do
1653
2040
  @jobs[@job_id] = {pid: pid, cmd: @cmd, status: :running}
1654
2041
  puts "[#{@job_id}] #{pid} #{@cmd}"
1655
2042
  else
1656
- # Better handling of pipes and redirections
2043
+ # Better handling of pipes and redirections
1657
2044
  @current_pid = spawn(@cmd)
1658
2045
  Process.wait(@current_pid)
1659
2046
  @last_exit = $?.exitstatus
1660
2047
  @current_pid = nil
1661
2048
  puts " Command failed: #{@cmd} (exit #{@last_exit})" unless @last_exit == 0
1662
2049
  end
2050
+
2051
+ # Track execution time
2052
+ elapsed = Time.now - start_time
2053
+ if cmd_base && elapsed > 0.01 # Only track if > 10ms
2054
+ @cmd_stats[cmd_base] ||= {count: 0, total_time: 0.0, avg_time: 0.0}
2055
+ @cmd_stats[cmd_base][:count] += 1
2056
+ @cmd_stats[cmd_base][:total_time] += elapsed
2057
+ @cmd_stats[cmd_base][:avg_time] = @cmd_stats[cmd_base][:total_time] / @cmd_stats[cmd_base][:count]
2058
+ end
2059
+
1663
2060
  post_cmd
1664
2061
  rescue StandardError => err
1665
2062
  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: 2.12.0
4
+ version: 3.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Geir Isene
@@ -12,8 +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
- v2.12.0: Extensible command completion system - smart tab completion for git, apt,
16
- docker, systemctl, cargo, npm, gem, bundle with easy customization.'
15
+ v3.0.0: MAJOR RELEASE - Persistent defuns, smart command suggestions with typo detection,
16
+ command analytics with :stats, switch caching, enhanced bookmarks with tags, session
17
+ save/restore, syntax validation, option value completion, and performance tracking.'
17
18
  email: g@isene.com
18
19
  executables:
19
20
  - rsh