ruby-shell 2.11.1 → 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 +495 -71
  4. metadata +5 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ea3ed53a591708e2637c362c1286a489f75a6cc549d8d057b3b2352594225142
4
- data.tar.gz: 3fa6fbf94b9cea324a7b2f48ecd42858f44055bdd014b3604424b26551f3ba9e
3
+ metadata.gz: 121b91f0e19b4e13cd6cacf00094552851ea44bf376c4e1f0e6554c83ee99a34
4
+ data.tar.gz: e63b51b9f7742b87b4bbc96ee41da6ba12ef93df0631b4ac564548b87f6d0a33
5
5
  SHA512:
6
- metadata.gz: 677514dfac4853286598fcf60e0b8332ef89540b01aba2ec1b133b9352348c6a757cce46228d42f86073481c0663e2374c5d7ede28b7f8116b8b977fa141ca70
7
- data.tar.gz: f94f381747aff0afd4ef8adca14b8e589a9929806c332a159c25c0a5772aac9ccb6bf1969c3f8ebda502fdeb782574ebb60eb8e8d92ecb9cac9219f30fa565e5
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.11.0" # Enhanced TAB completion with smart context-aware completion, frequency scoring, fuzzy matching, and more
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
@@ -119,6 +120,25 @@ begin # Initialization
119
120
  @job_id = 0 # Job counter
120
121
  @ai_suggestion = nil # Store AI command suggestion
121
122
  @last_exit = 0 # Last command exit status
123
+ # Command completions for subcommands
124
+ @cmd_completions = {
125
+ "git" => %w[add bisect branch checkout clone commit diff fetch grep init log merge mv pull push rebase reset restore rm show stash status switch tag],
126
+ "apt" => %w[install remove update upgrade search show list autoremove purge],
127
+ "apt-get" => %w[install remove update upgrade dist-upgrade autoremove purge clean autoclean],
128
+ "docker" => %w[build run ps images pull push start stop restart rm rmi exec logs inspect network volume],
129
+ "systemctl" => %w[start stop restart reload status enable disable is-active is-enabled list-units],
130
+ "cargo" => %w[build run test check clean doc new init add search publish install update],
131
+ "npm" => %w[install uninstall update run build test start init publish],
132
+ "gem" => %w[install uninstall update list search build push],
133
+ "bundle" => %w[install update exec check config]
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
122
142
  def pre_cmd; end # User-defined function to be run BEFORE command execution
123
143
  def post_cmd; end # User-defined function to be run AFTER command execution
124
144
  end
@@ -126,31 +146,37 @@ end
126
146
  # HELP TEXT
127
147
  @info = <<~INFO
128
148
 
129
- Hello #{@user}, welcome to rsh - the Ruby SHell.
130
-
131
- rsh does not attempt to compete with the grand old shells like bash and zsh.
132
- 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
133
153
  to ask for more or different features here: https://github.com/isene/rsh.
134
-
135
- Features:
154
+
155
+ Core Features:
136
156
  * Aliases (called nicks in rsh) - both for commands and general nicks
137
- * Syntax highlighting, matching nicks, system commands and valid dirs/files
157
+ * Syntax highlighting for nicks, bookmarks, commands, switches and valid dirs/files
138
158
  * Tab completions for nicks, system commands, command switches and dirs/files
139
- * Tab completion presents matches in a list to pick from
140
- * When you start to write a command, rsh will suggest the first match in the history and
141
- present that in "toned down" letters - press the arrow right key to accept the suggestion
142
- * Writing a partial command and pressing `UP` will search history for matches.
143
- Go down/up in the list and press `TAB` or `ENTER` to accept, `Ctrl-g` or `Ctrl-c` to discard
144
- * History with editing, search and repeat a history command (with `!`)
145
- * Config file (.rshrc) updates on exit (with Ctrl-d) or not (with Ctrl-e)
146
- * Set of simple rsh specific commands like nick, nick?, history and rmhistory
147
- * rsh specific commands and full set of Ruby commands available via :<command>
148
- * All colors are themeable in .rshrc (see github link for possibilities)
149
- * Copy current command line to primary selection (paste w/middle button) with `Ctrl-y`
150
- * AI integration: Use @ for text responses and @@ for command suggestions (requires ollama or OpenAI)
151
-
152
- Use `:help` for command reference.
153
-
159
+ * Smart context-aware tab completion for git, apt, docker, systemctl, cargo, npm, gem, bundle
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
+
154
180
  INFO
155
181
 
156
182
  @help = <<~HELP
@@ -179,6 +205,7 @@ def firstrun
179
205
  @c_gnick = 87 # Color for matching gnick
180
206
  @c_path = 208 # Color for valid path
181
207
  @c_switch = 148 # Color for switches/options
208
+ @c_bookmark = 13 # Color for bookmarks
182
209
  @c_tabselect = 207 # Color for selected tabcompleted item
183
210
  @c_taboption = 244 # Color for unselected tabcompleted item
184
211
  @c_stamp = 244 # Color for time stamp/command
@@ -455,7 +482,8 @@ def tab(type)
455
482
  @tabstr = @pretab if type == "hist" # Searching for matches with whole string in history
456
483
  @pretab = @pretab.delete_suffix(@tabstr)
457
484
  end
458
- type = "switch" if @tabstr && @tabstr[0] == "-"
485
+ type = "switch" if @tabstr && @tabstr[0] == "-" && !@tabstr.include?("=")
486
+ type = "option_value" if @tabstr && @tabstr =~ /^--?[\w-]+=/
459
487
  type = "env_vars" if @tabstr && @tabstr[0] == "$"
460
488
 
461
489
  # Debug output when RSH_DEBUG is set
@@ -484,12 +512,16 @@ def tab(type)
484
512
  type = "dirs_only"
485
513
  when "vim", "nano", "cat", "less", "more", "head", "tail", "file"
486
514
  type = "files_only"
487
- when "git"
488
- type = "git_subcommands" if cmd_parts.length == 1
489
515
  when "man", "info", "which", "whatis"
490
516
  type = "commands_only"
491
517
  when "export", "unset"
492
518
  type = "env_vars"
519
+ else
520
+ # Check if command has defined completions and we're on the first argument
521
+ if @cmd_completions.key?(last_cmd) && cmd_parts.length == 1
522
+ type = "cmd_subcommands"
523
+ @current_cmd = last_cmd
524
+ end
493
525
  end
494
526
  end
495
527
 
@@ -500,12 +532,29 @@ def tab(type)
500
532
  @tabarray.shift # Take away @history[0]
501
533
  return if @tabarray.empty?
502
534
  when "switch"
503
- cmdswitch = @pretab.split(/[|, ]/).last.to_s
504
- hlp = `#{cmdswitch} --help 2>/dev/null`
505
- hlp = hlp.split("\n").grep(/^\s*-{1,2}[^-]/)
506
- hlp = hlp.map{|h| h.sub(/^\s*/, '').sub(/^--/, ' --')}
507
- hlp = hlp.reject{|h| /-</ =~ h}
508
- @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
509
558
  when "dirs_only" # Only show directories
510
559
  fdir = @tabstr + "*"
511
560
  dirs = Dir.glob(fdir).select { |d| Dir.exist?(d) }.map { |d| d + "/" }
@@ -519,10 +568,10 @@ def tab(type)
519
568
  ex.prepend(*@nick.keys, *@gnick.keys)
520
569
  regex_flags = @completion_case_sensitive ? 0 : Regexp::IGNORECASE
521
570
  @tabarray = ex.select { |s| s =~ Regexp.new(@tabstr, regex_flags) }
522
- when "git_subcommands" # Git subcommands
523
- git_cmds = %w[add branch checkout clone commit diff fetch init log merge pull push rebase reset status tag]
571
+ when "cmd_subcommands" # Command-specific subcommands (git, apt, docker, etc.)
572
+ subcommands = @cmd_completions[@current_cmd] || []
524
573
  regex_flags = @completion_case_sensitive ? 0 : Regexp::IGNORECASE
525
- @tabarray = git_cmds.select { |cmd| cmd =~ Regexp.new(@tabstr, regex_flags) }
574
+ @tabarray = subcommands.select { |cmd| cmd =~ Regexp.new(@tabstr, regex_flags) }
526
575
  when "env_vars" # Environment variables
527
576
  env_vars = ENV.keys.map { |k| "$#{k}" }
528
577
  regex_flags = @completion_case_sensitive ? 0 : Regexp::IGNORECASE
@@ -700,6 +749,85 @@ def tabend
700
749
  @c_col = @pos0 + @pos
701
750
  @c.col(@c_col)
702
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
703
831
  def hist_clean # Clean up @history
704
832
  @history.uniq!
705
833
  @history.compact!
@@ -717,6 +845,7 @@ def cmd_check(str) # Check if each element on the readline matches commands, nic
717
845
 
718
846
  str.gsub(/(?:\S'[^']*'|[^ '])+/) do |el|
719
847
  clean_el = el.gsub("'", "")
848
+ # Priority: commands > nicks > bookmarks > paths
720
849
  if @exe.include?(el)
721
850
  el.c(@c_cmd)
722
851
  elsif el == "cd"
@@ -724,8 +853,6 @@ def cmd_check(str) # Check if each element on the readline matches commands, nic
724
853
  elsif clean_el =~ /^\.\/(.+)/ && File.exist?(clean_el) && File.executable?(clean_el)
725
854
  # Color local executables starting with ./
726
855
  el.c(@c_cmd)
727
- elsif File.exist?(clean_el)
728
- el.c(@c_path)
729
856
  elsif @nick.include?(el)
730
857
  el.c(@c_nick)
731
858
  elsif el == "r" or el == "f"
@@ -734,6 +861,11 @@ def cmd_check(str) # Check if each element on the readline matches commands, nic
734
861
  el.c(@c_gnick)
735
862
  elsif self.respond_to?(el) && singleton_class.instance_methods(false).include?(el.to_sym)
736
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)
737
869
  elsif el[0] == "-"
738
870
  el.c(@c_switch)
739
871
  else
@@ -754,6 +886,16 @@ def rshrc # Write updates to .rshrc
754
886
  conf += "@gnick = #{@gnick}\n"
755
887
  conf.sub!(/^@cmd_frequency.*(\n|$)/, "")
756
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?
895
+ # Only write @cmd_completions if user has customized it
896
+ unless conf =~ /^@cmd_completions\s*=/
897
+ # Don't write default completions to avoid cluttering .rshrc
898
+ end
757
899
  conf.sub!(/^@history.*(\n|$)/, "")
758
900
  # Ensure history is properly formatted as valid Ruby array
759
901
  begin
@@ -799,9 +941,10 @@ def help
799
941
  left_col << "SPECIAL COMMANDS:".c(@c_prompt).b
800
942
  left_col << ":nick 'll = ls -l' Command alias"
801
943
  left_col << ":gnick 'h = /home' General alias"
802
- left_col << ":nickdel 'name' Delete nick"
803
- left_col << ":gnickdel 'name' Delete gnick"
804
- 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"
805
948
  left_col << ":history Show history"
806
949
  left_col << ":rmhistory Clear history"
807
950
  left_col << ":info About rsh"
@@ -821,6 +964,16 @@ def help
821
964
  right_col << ":fg [id] Foreground job"
822
965
  right_col << ":bg [id] Resume in bg"
823
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 << ""
824
977
  right_col << "INTEGRATIONS:".c(@c_prompt).b
825
978
  right_col << "r Launch rtfm"
826
979
  right_col << "f Launch fzf"
@@ -831,6 +984,12 @@ def help
831
984
  right_col << "@ <question> AI text response"
832
985
  right_col << "@@ <request> AI command → prompt"
833
986
  right_col << ""
987
+ right_col << "SMART COMPLETIONS:".c(@c_prompt).b
988
+ right_col << "git <TAB> Git subcommands"
989
+ right_col << "apt/docker <TAB> Command options"
990
+ right_col << "--format=<TAB> Option values"
991
+ right_col << "Typo suggestions Auto-correct"
992
+ right_col << ""
834
993
  right_col << "EXPANSIONS:".c(@c_prompt).b
835
994
  right_col << "~ Home directory"
836
995
  right_col << "$VAR, ${VAR} Environment var"
@@ -872,43 +1031,47 @@ def rmhistory # Delete history
872
1031
  @history = []
873
1032
  puts "History deleted."
874
1033
  end
875
- def nick(nick_str) # Define a new nick like this: `:nick "ls = ls --color"`
876
- 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*-/)
877
1045
  source = nick_str.sub(/^\s*-/, '')
878
1046
  @nick.delete(source)
1047
+ rshrc
879
1048
  else
880
1049
  source = nick_str.sub(/ =.*/, '')
881
1050
  target = nick_str.sub(/.*= /, '')
882
1051
  @nick[source] = target
1052
+ rshrc
883
1053
  end
884
- rshrc
885
1054
  end
886
- def gnick(nick_str) # Define a generic/global nick to match not only commands (format like nick)
887
- 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*-/)
888
1066
  source = nick_str.sub(/^\s*-/, '')
889
1067
  @gnick.delete(source)
1068
+ rshrc
890
1069
  else
891
1070
  source = nick_str.sub(/ =.*/, '')
892
1071
  target = nick_str.sub(/.*= /, '')
893
1072
  @gnick[source] = target
1073
+ rshrc
894
1074
  end
895
- rshrc
896
- end
897
- def nick? # Show nicks
898
- puts " Command nicks:".c(@c_nick)
899
- @nick.sort.each {|key, value| puts " #{key} = #{value}"}
900
- puts " General nicks:".c(@c_gnick)
901
- @gnick.sort.each {|key, value| puts " #{key} = #{value}"}
902
- end
903
- def nickdel(nick_name) # Delete a command nick
904
- @nick.delete(nick_name)
905
- rshrc
906
- puts "Nick '#{nick_name}' deleted"
907
- end
908
- def gnickdel(nick_name) # Delete a general/global nick
909
- @gnick.delete(nick_name)
910
- rshrc
911
- puts "General nick '#{nick_name}' deleted"
912
1075
  end
913
1076
  def dirs
914
1077
  puts "Past direactories:"
@@ -967,6 +1130,7 @@ def defun(func_def) # Define a Ruby function like: `:defun "myls(*args) = Dir.g
967
1130
  func_name = func_def.sub(/^\s*-/, '')
968
1131
  if self.respond_to?(func_name)
969
1132
  singleton_class.remove_method(func_name.to_sym)
1133
+ @defuns.delete(func_name)
970
1134
  puts "Function '#{func_name}' removed"
971
1135
  else
972
1136
  puts "Function '#{func_name}' not found"
@@ -978,11 +1142,12 @@ def defun(func_def) # Define a Ruby function like: `:defun "myls(*args) = Dir.g
978
1142
  func_name = $1
979
1143
  func_params = $2
980
1144
  func_body = $3
981
-
1145
+
982
1146
  begin
983
1147
  eval_code = "def #{func_name}(#{func_params}); #{func_body}; end"
984
1148
  puts " DEBUG: Evaluating: #{eval_code}" if ENV['RSH_DEBUG']
985
1149
  singleton_class.class_eval(eval_code)
1150
+ @defuns[func_name] = func_def # Store for persistence
986
1151
  puts "Function '#{func_name}' defined"
987
1152
  puts " DEBUG: Method created? #{respond_to?(func_name)}" if ENV['RSH_DEBUG']
988
1153
  rescue SyntaxError => e
@@ -1011,6 +1176,202 @@ def defun? # Show all user-defined functions
1011
1176
  end
1012
1177
  end
1013
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
1014
1375
  def execute_conditional(cmd_line)
1015
1376
  # Split on && and || while preserving the operators
1016
1377
  parts = cmd_line.split(/(\s*&&\s*|\s*\|\|\s*)/)
@@ -1289,6 +1650,25 @@ def load_rshrc_safe
1289
1650
  @nick = {} unless @nick.is_a?(Hash)
1290
1651
  @gnick = {} unless @gnick.is_a?(Hash)
1291
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
1292
1672
 
1293
1673
  rescue SyntaxError => e
1294
1674
  puts "\n\033[31mERROR: Syntax error in .rshrc:\033[0m"
@@ -1402,6 +1782,11 @@ def load_defaults
1402
1782
  @completion_show_descriptions ||= false
1403
1783
  @completion_fuzzy ||= true
1404
1784
  @cmd_frequency ||= {}
1785
+ @cmd_stats ||= {}
1786
+ @bookmarks ||= {}
1787
+ @defuns ||= {}
1788
+ @switch_cache ||= {}
1789
+ @switch_cache_time ||= {}
1405
1790
  puts "Loaded with default configuration."
1406
1791
  end
1407
1792
 
@@ -1482,7 +1867,7 @@ loop do
1482
1867
  begin
1483
1868
  @user = Etc.getpwuid(Process.euid).name # For use in @prompt
1484
1869
  @node = Etc.uname[:nodename] # For use in @prompt
1485
- 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
1486
1871
  @prompt.gsub!(/#{Dir.home}/, '~') # Simplify path in prompt
1487
1872
  system("printf \"\033]0;rsh: #{Dir.pwd}\007\"") # Set Window title to path
1488
1873
  @history[0] = "" unless @history[0]
@@ -1582,11 +1967,22 @@ loop do
1582
1967
  @cmd = Dir.home if @cmd == "~"
1583
1968
  @cmd = @dirs[1] if @cmd == "-"
1584
1969
  @cmd = @dirs[@cmd.to_i] if @cmd =~ /^\d$/
1585
- # Check if it's a directory to change to
1970
+ # Check if it's a directory to change to first
1586
1971
  dir = @cmd.strip.sub(/~/, Dir.home)
1587
1972
  if Dir.exist?(dir)
1588
- Dir.chdir(dir)
1973
+ Dir.chdir(dir)
1589
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
1590
1986
  else
1591
1987
  puts "#{Time.now.strftime("%H:%M:%S")}: #{@cmd}".c(@c_stamp)
1592
1988
  if @cmd == "f" # fzf integration (https://github.com/junegunn/fzf)
@@ -1603,16 +1999,34 @@ loop do
1603
1999
  Thread.new { system("xdg-open #{@cmd} 2>/dev/null") }
1604
2000
  end
1605
2001
  end
1606
- else
2002
+ else
1607
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
+
1608
2019
  pre_cmd
1609
2020
 
1610
2021
  # Track command frequency for intelligent completion
1611
- if @cmd && !@cmd.empty?
1612
- cmd_base = @cmd.split.first
1613
- @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
1614
2025
  end
1615
2026
 
2027
+ # Start timing
2028
+ start_time = Time.now
2029
+
1616
2030
  # Handle background jobs
1617
2031
  if @cmd.end_with?(' &')
1618
2032
  @cmd = @cmd[0..-3] # Remove the &
@@ -1626,13 +2040,23 @@ loop do
1626
2040
  @jobs[@job_id] = {pid: pid, cmd: @cmd, status: :running}
1627
2041
  puts "[#{@job_id}] #{pid} #{@cmd}"
1628
2042
  else
1629
- # Better handling of pipes and redirections
2043
+ # Better handling of pipes and redirections
1630
2044
  @current_pid = spawn(@cmd)
1631
2045
  Process.wait(@current_pid)
1632
2046
  @last_exit = $?.exitstatus
1633
2047
  @current_pid = nil
1634
2048
  puts " Command failed: #{@cmd} (exit #{@last_exit})" unless @last_exit == 0
1635
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
+
1636
2060
  post_cmd
1637
2061
  rescue StandardError => err
1638
2062
  puts "\nError: #{err}"
metadata CHANGED
@@ -1,19 +1,20 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby-shell
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.11.1
4
+ version: 3.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Geir Isene
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-10-04 00:00:00.000000000 Z
11
+ 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.11.1: Critical bugfix for nick persistence - nicks now save correctly to .rshrc
16
- without duplication when file doesn''t end with newline.'
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