ruby-shell 2.12.0 → 3.1.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 +47 -9
  3. data/bin/rsh +880 -70
  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: 318b51625674f856dfa297d3b8fc8a3bf0e2fd2bb87e4f95d4184bae6e94be72
4
+ data.tar.gz: 03cfd00609fc1f2a011458d3a75837adfe2ac3975c38718b8fcca9998ffa22b6
5
5
  SHA512:
6
- metadata.gz: dfa2498cfa54eb2b36170848c24f55026e45dbfa4926c4367efb866c45508e24beb52f841efbc08be376a8b8e4f9e0ec772c45bed479c48f6e8533107dd2b47a
7
- data.tar.gz: 899f55fc361186ac32ec1a3ccf62243003a3d500a04ac5c25138a767abbad47940116511dce00c58ec40bf97d37fc092a968f5a85ddaf1943afe307770fc3a38
6
+ metadata.gz: 982ec206a79d08fed5b95324b356edd4069c158bef62b52cf0205906bef39f38c8e44031821cf7c4f7eec8f9162df61e186589dee240a6074ae8dc592f090393
7
+ data.tar.gz: b0d025d2b2a0df814b3a9239f2151bcecc66a35ca71f0f020aa360ee2b0081cecbe963e6a2124fa35993f3595652e4797760219e88a423390f6b6825e77d8207
data/README.md CHANGED
@@ -33,7 +33,30 @@ 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.1.0 - Quick Wins & Polish
37
+ * **Multiple Named Sessions**: Save/load different sessions - `:save_session "project"`, `:load_session "project"`
38
+ * **Stats Export**: Export analytics to CSV/JSON - `:stats --csv` or `:stats --json`
39
+ * **Session Auto-save**: Set `@session_autosave = 300` in .rshrc for automatic 5-minute saves
40
+ * **Bookmark Import/Export**: Share bookmarks - `:bm --export bookmarks.json`, `:bm --import bookmarks.json`
41
+ * **Bookmark Statistics**: See usage patterns - `:bm --stats` shows tag distribution and analytics
42
+ * **Color Themes**: 6 preset themes - `:theme solarized|dracula|gruvbox|nord|monokai|default`
43
+ * **Config Management**: `:config` shows/sets history_dedup, session_autosave, completion settings
44
+ * **Environment Variables**: `:env` lists/sets/exports environment variables
45
+ * **Bookmark TAB Completion**: Bookmarks appear in TAB completion alongside commands
46
+ * **List Sessions**: `:list_sessions` shows all saved sessions with timestamps and paths
47
+
48
+ ## v3.0.0 - Major Feature Release ⭐⭐⭐
49
+ * **Persistent Ruby Functions**: defun functions now save to .rshrc and persist across sessions
50
+ * **Smart Command Suggestions**: Typo detection with "Did you mean...?" suggestions using Levenshtein distance
51
+ * **Command Analytics**: New `:stats` command shows usage statistics, performance metrics, and most-used commands
52
+ * **Switch Completion Caching**: Command switches from --help are cached for instant completion
53
+ * **Enhanced Bookmarks**: Bookmark directories with tags - `:bookmark name path #tag1,tag2`
54
+ * **Session Management**: Save and restore entire shell sessions with `:save_session` and `:load_session`
55
+ * **Syntax Validation**: Pre-execution warnings for common mistakes, dangerous commands, and typos
56
+ * **Option Value Completion**: TAB completion for option values like `--format=<TAB>` → json, yaml, xml
57
+ * **Command Performance Tracking**: Automatically tracks execution time and shows slowest commands
58
+
59
+ ## AI Integration (v2.9.0) ⭐
37
60
  * **AI-powered command assistance**: Get help with commands using natural language
38
61
  * **`@ <question>`**: Ask questions and get AI-generated text responses
39
62
  * **`@@ <request>`**: Describe what you want to do, and AI suggests the command
@@ -73,13 +96,18 @@ Special functions/integrations:
73
96
  Special commands:
74
97
  * `:nick 'll = ls -l'` to make a command alias (ll) point to a command (ls -l)
75
98
  * `: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)
99
+ * `:nick` lists all command nicks, `:gnick` lists general nicks (NEW in v3.0)
100
+ * `:nick '-name'` delete a command nick, `:gnick '-name'` delete a general nick (NEW in v3.0)
79
101
  * `:history` will list the command history, while `:rmhistory` will delete the history
80
102
  * `: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
103
+ * `:defun 'func(args) = code'` defines Ruby functions callable as shell commands (now persistent!)
82
104
  * `:defun?` lists all user-defined functions, `:defun '-func'` removes functions
105
+ * `:stats` shows command execution statistics and analytics (NEW in v3.0)
106
+ * `:bm "name"` or `:bookmark "name"` bookmark current directory, `:bm "name path #tags"` with tags (NEW in v3.0)
107
+ * `:bm` lists all bookmarks, just type bookmark name to jump (e.g., `work`) (NEW in v3.0)
108
+ * `:bm "-name"` delete bookmark, `:bm "?tag"` search by tag (NEW in v3.0)
109
+ * `:save_session "name"` saves named session, `:load_session "name"` loads session (NEW in v3.0)
110
+ * `:list_sessions` shows all saved sessions, `:rmsession "name"` or `:rmsession "*"` deletes (NEW in v3.1)
83
111
  * `:info` shows introduction and feature overview
84
112
  * `:version` Shows the rsh version number and the last published gem file version
85
113
  * `:help` will display a compact command reference in two columns
@@ -109,12 +137,19 @@ Add to your `.rshrc`:
109
137
  While you `cd` around to different directories, you can see the last 10 directories visited via the command `:dirs` or the convenient shortcut `#`. Entering the number in the list (like `6` and ENTER) will jump you to that directory. Entering `-` will jump you back to the previous dir (equivalent of `1`. Entering `~` will get you to your home dir. If you want to bookmark a special directory, you can do that via a general nick like this: `:gnick "x = /path/to/a/dir/"` - this would bookmark the directory to the single letter `x`.
110
138
 
111
139
  ## Nicks
112
- Add command nicks (aliases) with `:nick "some_nick = some_command"`, e.g. `:nick "ls = ls --color"`. Add general nicks that will substitute anything on a command line (not just commands) like this `:gnick "some_gnick = some_command"`, e.g. `:gnick "x = /home/user/somewhere"`. List (g)nicks with `:nick?`. Remove a nick with `:nick "-some_command"`, e.g. `:nick "-ls"` to remove an `ls` nick. Same for gnicks.
140
+ Add command nicks (aliases) with `:nick "some_nick = some_command"`, e.g. `:nick "ls = ls --color"`. Add general nicks that will substitute anything on a command line (not just commands) like this `:gnick "some_gnick = some_command"`, e.g. `:gnick "x = /home/user/somewhere"`. List nicks with `:nick`, list gnicks with `:gnick`. Remove a nick with `:nick "-some_command"`, e.g. `:nick "-ls"` to remove an `ls` nick. Same for gnicks.
113
141
 
114
142
  ## Tab completion
115
- You can tab complete almost anything. Hitting `TAB` will try to complete in this priority: nicks, gnicks, commands, dirs/files. Hitting `TAB`after a `-` will list the command switches for the preceding command with a short explanation (from the command's --help), like this `ls -`(`TAB`) will list all the switches/options for the `ls` command. You can add to (or subtract from) the search criteria while selecting possible matches - hit any letter to specify the search, while backspace removes a letter from the search criteria.
143
+ You can tab complete almost anything. Hitting `TAB` will try to complete in this priority: nicks, gnicks, commands, dirs/files. Special completions:
144
+ - `ls -<TAB>` lists command switches from --help with descriptions
145
+ - `:st<TAB>` completes colon commands (:stats, etc.)
146
+ - `$HO<TAB>` completes environment variables ($HOME, etc.)
147
+ - `git <TAB>` shows git subcommands (add, commit, push, etc.)
148
+ - `--format=<TAB>` completes option values (json, yaml, xml, etc.)
116
149
 
117
- Hitting Shift-TAB will do a similar search through the command history - but with a general match of the search criteria (not only matching at the start).
150
+ You can add to (or subtract from) the search criteria while selecting matches - hit any letter to refine the search, backspace removes a letter from the criteria.
151
+
152
+ Hitting Shift-TAB will search through the command history with fuzzy matching.
118
153
 
119
154
  ## Open files
120
155
  If you press `ENTER` after writing or tab-completing to a file, rsh will try to open the file in the user's EDITOR of choice (if it is a valid text file) or use `xdg-open` to open the file using the correct program. If you, for some reason want to use `run-mailcap` instead of `xdg-open` as the file opener, simply add `@runmailcap = true` to your `.rshrc`.
@@ -185,7 +220,7 @@ Enter the command `f` to launch the fuzzy finder - select the directory/file you
185
220
  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
221
 
187
222
  ## Syntax highlighting
188
- rsh will highlight nicks, gnicks, commands and dirs/files as they are written on the command line.
223
+ 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
224
 
190
225
  ## Theming
191
226
  In the supplied `.rshrc`, you will find a set of colors that you can change:
@@ -197,6 +232,9 @@ Variable | Description
197
232
  `@c_nick` | Color for matching nick
198
233
  `@c_gnick` | Color for matching gnick
199
234
  `@c_path` | Color for valid path
235
+ `@c_switch` | Color for command switches/options
236
+ `@c_bookmark` | Color for bookmarks (NEW in v3.0)
237
+ `@c_colon` | Color for colon commands (NEW in v3.1)
200
238
  `@c_tabselect` | Color for selected tabcompleted item
201
239
  `@c_taboption` | Color for unselected tabcompleted item
202
240
  `@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.1.0" # Quick wins: Multiple sessions, stats export, bookmark features, themes, config, env management
12
12
 
13
13
  # MODULES, CLASSES AND EXTENSIONS
14
14
  class String # Add coloring to strings (with escaping for Readline)
@@ -92,6 +92,8 @@ 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
96
+ @c_colon = 4 # Color for colon commands
95
97
  @c_tabselect = 5 # Color for selected tabcompleted item
96
98
  @c_taboption = 244 # Color for unselected tabcompleted item
97
99
  @c_stamp = 244 # Color for time stamp/command
@@ -131,6 +133,19 @@ begin # Initialization
131
133
  "gem" => %w[install uninstall update list search build push],
132
134
  "bundle" => %w[install update exec check config]
133
135
  }
136
+ # New v3.0 features initialization
137
+ @switch_cache = {} # Cache for command switches from --help
138
+ @switch_cache_time = {} # Timestamp for cache expiry
139
+ @bookmarks = {} # Enhanced bookmarks with tags
140
+ @defuns = {} # Store defun definitions for persistence
141
+ @cmd_stats = {} # Command execution statistics
142
+ @session_dir = Dir.home + '/.rsh/sessions' # Sessions directory
143
+ @session_file = @session_dir + '/default.json' # Default session file
144
+ @session_autosave = 0 # Auto-save interval (0 = disabled)
145
+ @session_last_save = Time.now.to_i # Last auto-save timestamp
146
+ @history_dedup = 'smart' # History dedup mode: 'off', 'full', 'smart'
147
+ Dir.mkdir(Dir.home + '/.rsh') unless Dir.exist?(Dir.home + '/.rsh')
148
+ Dir.mkdir(@session_dir) unless Dir.exist?(@session_dir)
134
149
  def pre_cmd; end # User-defined function to be run BEFORE command execution
135
150
  def post_cmd; end # User-defined function to be run AFTER command execution
136
151
  end
@@ -138,32 +153,49 @@ end
138
153
  # HELP TEXT
139
154
  @info = <<~INFO
140
155
 
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
156
+ Hello #{@user}, welcome to rsh v3.1 - the Ruby SHell.
157
+
158
+ rsh does not attempt to compete with the grand old shells like bash and zsh.
159
+ It serves the specific needs and wants of its author. If you like it, then feel free
145
160
  to ask for more or different features here: https://github.com/isene/rsh.
146
-
147
- Features:
161
+
162
+ Core Features:
148
163
  * Aliases (called nicks in rsh) - both for commands and general nicks
149
- * Syntax highlighting, matching nicks, system commands and valid dirs/files
164
+ * Syntax highlighting for nicks, bookmarks, commands, switches and valid dirs/files
150
165
  * Tab completions for nicks, system commands, command switches and dirs/files
151
166
  * 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
-
167
+ * History with editing, search and repeat (use `!` or UP arrow)
168
+ * Auto-suggestions from history (press RIGHT arrow to accept)
169
+ * Ruby functions callable as shell commands (persistent across sessions)
170
+ * AI integration: Use @ for text responses and @@ for command suggestions
171
+
172
+ NEW in v3.0:
173
+ * Command analytics - :stats shows usage patterns and performance metrics
174
+ * Enhanced bookmarks with tags - :bm "name path #tag1,tag2" then just type name to jump
175
+ * Session management - :save_session and :load_session preserve your entire shell state
176
+ * Smart typo detection - "Did you mean...?" suggestions for misspelled commands
177
+ * Switch caching - Faster TAB completion for command options
178
+ * Option value completion - TAB complete values like --format=json
179
+ * Syntax validation - Pre-execution warnings for dangerous or malformed commands
180
+ * Unified command syntax - :nick, :gnick, :bm all work the same way (list/create/delete)
181
+
182
+ NEW in v3.1:
183
+ * Multiple named sessions - :save_session "project" and :load_session "project"
184
+ * Stats export - :stats --csv or :stats --json for data analysis
185
+ * Session auto-save - Set @session_autosave = 300 in .rshrc for 5-min auto-save
186
+ * Bookmark import/export - :bm --export file.json and :bm --import file.json
187
+ * Bookmark statistics - :bm --stats shows usage patterns and tag distribution
188
+ * Color themes - :theme solarized|dracula|gruvbox|nord|monokai
189
+ * Config management - :config shows/sets history_dedup, session_autosave, etc.
190
+ * 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
+
194
+ Config file (.rshrc) updates on exit (Ctrl-d) or not (Ctrl-e).
195
+ All colors are themeable in .rshrc (see github link for possibilities).
196
+
197
+ Use `:help` for complete command reference.
198
+
167
199
  INFO
168
200
 
169
201
  @help = <<~HELP
@@ -192,6 +224,8 @@ def firstrun
192
224
  @c_gnick = 87 # Color for matching gnick
193
225
  @c_path = 208 # Color for valid path
194
226
  @c_switch = 148 # Color for switches/options
227
+ @c_bookmark = 13 # Color for bookmarks
228
+ @c_colon = 4 # Color for colon commands
195
229
  @c_tabselect = 207 # Color for selected tabcompleted item
196
230
  @c_taboption = 244 # Color for unselected tabcompleted item
197
231
  @c_stamp = 244 # Color for time stamp/command
@@ -468,8 +502,10 @@ def tab(type)
468
502
  @tabstr = @pretab if type == "hist" # Searching for matches with whole string in history
469
503
  @pretab = @pretab.delete_suffix(@tabstr)
470
504
  end
471
- type = "switch" if @tabstr && @tabstr[0] == "-"
505
+ type = "switch" if @tabstr && @tabstr[0] == "-" && !@tabstr.include?("=")
506
+ type = "option_value" if @tabstr && @tabstr =~ /^--?[\w-]+=/
472
507
  type = "env_vars" if @tabstr && @tabstr[0] == "$"
508
+ type = "colon_commands" if @tabstr && @tabstr[0] == ":"
473
509
 
474
510
  # Debug output when RSH_DEBUG is set
475
511
  if ENV['RSH_DEBUG']
@@ -517,12 +553,29 @@ def tab(type)
517
553
  @tabarray.shift # Take away @history[0]
518
554
  return if @tabarray.empty?
519
555
  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
556
+ cmdswitch = @pretab.split(/[|, ]/).last.to_s.strip
557
+ @tabarray = get_command_switches(cmdswitch)
558
+ when "option_value" # Completion for option values like --format=<value>
559
+ if @tabstr =~ /^--?[\w-]+=(.*)/
560
+ value_prefix = $1
561
+ option = @tabstr.sub(/=.*/, '')
562
+ # Define common option value completions
563
+ value_completions = {
564
+ /format/ => %w[json yaml xml csv plain],
565
+ /output/ => %w[json yaml xml text html],
566
+ /level|log-level/ => %w[debug info warn error fatal],
567
+ /color/ => %w[auto always never],
568
+ /type/ => %w[file dir link all]
569
+ }
570
+ matches = []
571
+ value_completions.each do |pattern, values|
572
+ if option =~ pattern
573
+ matches = values.select { |v| v.start_with?(value_prefix) }
574
+ break unless matches.empty?
575
+ end
576
+ end
577
+ @tabarray = matches.map { |v| "#{option}=#{v}" }
578
+ end
526
579
  when "dirs_only" # Only show directories
527
580
  fdir = @tabstr + "*"
528
581
  dirs = Dir.glob(fdir).select { |d| Dir.exist?(d) }.map { |d| d + "/" }
@@ -544,12 +597,25 @@ def tab(type)
544
597
  env_vars = ENV.keys.map { |k| "$#{k}" }
545
598
  regex_flags = @completion_case_sensitive ? 0 : Regexp::IGNORECASE
546
599
  @tabarray = env_vars.select { |var| var =~ Regexp.new(@tabstr, regex_flags) }
600
+ when "colon_commands" # Ruby/rsh commands starting with :
601
+ colon_cmds = %w[
602
+ :nick :gnick :bm :bookmark :stats :defun :defun?
603
+ :history :rmhistory :jobs :fg :bg
604
+ :save_session :load_session :list_sessions :delete_session :rmsession
605
+ :config :env :theme
606
+ :info :version :help
607
+ ]
608
+ search_str = @tabstr[1..-1] || "" # Remove leading :
609
+ regex_flags = @completion_case_sensitive ? 0 : Regexp::IGNORECASE
610
+ matches = colon_cmds.select { |cmd| cmd[1..-1] =~ Regexp.new("^#{search_str}", regex_flags) }
611
+ @tabarray = matches
547
612
  when "all" # Handle all other tab completions
548
613
  ex = []
549
614
  ex += @exe
550
615
  ex.sort!
551
616
  ex.prepend(*@nick.keys) # Add nicks
552
617
  ex.prepend(*@gnick.keys) # Add gnicks
618
+ ex.prepend(*@bookmarks.keys) if @bookmarks # Add bookmarks
553
619
 
554
620
  # Enhanced matching with case sensitivity and fuzzy support
555
621
  regex_flags = @completion_case_sensitive ? 0 : Regexp::IGNORECASE
@@ -717,10 +783,176 @@ def tabend
717
783
  @c_col = @pos0 + @pos
718
784
  @c.col(@c_col)
719
785
  end
786
+ def get_command_switches(command) # Helper function to extract switches from --help
787
+ # Check cache first (cache expires after 1 hour)
788
+ cache_key = command.to_s.strip
789
+ return [] if cache_key.empty?
790
+
791
+ current_time = Time.now.to_i
792
+ if @switch_cache[cache_key] && @switch_cache_time[cache_key] && (current_time - @switch_cache_time[cache_key]) < 3600
793
+ return @switch_cache[cache_key]
794
+ end
795
+
796
+ # Parse --help output
797
+ hlp = `#{command} --help 2>/dev/null`
798
+ # Try -h if --help didn't work
799
+ hlp = `#{command} -h 2>/dev/null` if hlp.empty?
800
+ return [] if hlp.empty?
801
+
802
+ switches = []
803
+
804
+ # Method 1: Lines starting with switches (traditional format)
805
+ switches = hlp.split("\n").grep(/^\s*-{1,2}[^-]/)
806
+ switches.map! { |h| h.sub(/^\s*/, '').sub(/^--/, ' --') }
807
+ switches.reject! { |h| /-</ =~ h }
808
+
809
+ # Method 2: Extract switches from usage line (git-style format)
810
+ if switches.empty?
811
+ usage_lines = hlp.split("\n").select { |l| l =~ /usage:|Usage:/ }
812
+ usage_lines.each do |line|
813
+ # Extract all switches from the usage line
814
+ line.scan(/(-[a-zA-Z]|--[a-z-]+)/).each do |match|
815
+ switch = match[0]
816
+ switches << (switch.start_with?('--') ? " #{switch}" : switch)
817
+ end
818
+ end
819
+ switches.uniq!
820
+ end
821
+
822
+ # Cache the result (even if empty, to avoid repeated failures)
823
+ @switch_cache[cache_key] = switches
824
+ @switch_cache_time[cache_key] = current_time
825
+
826
+ switches
827
+ end
828
+ def levenshtein_distance(s, t) # Calculate edit distance for smart suggestions
829
+ m = s.length
830
+ n = t.length
831
+ return m if n == 0
832
+ return n if m == 0
833
+ d = Array.new(m+1) {Array.new(n+1)}
834
+ (0..m).each {|i| d[i][0] = i}
835
+ (0..n).each {|j| d[0][j] = j}
836
+ (1..n).each do |j|
837
+ (1..m).each do |i|
838
+ d[i][j] = if s[i-1] == t[j-1]
839
+ d[i-1][j-1]
840
+ else
841
+ [d[i-1][j]+1, d[i][j-1]+1, d[i-1][j-1]+1].min
842
+ end
843
+ end
844
+ end
845
+ d[m][n]
846
+ end
847
+ def suggest_command(cmd) # Smart command suggestions for typos
848
+ return nil if cmd.nil? || cmd.empty?
849
+ return nil if @exe.include?(cmd) || @nick.include?(cmd)
850
+
851
+ # Find commands with small edit distance
852
+ candidates = (@exe + @nick.keys).select do |c|
853
+ next false if c.length < 2
854
+ dist = levenshtein_distance(cmd, c)
855
+ max_dist = [cmd.length / 3, 2].max
856
+ dist <= max_dist && dist > 0
857
+ end
858
+
859
+ return nil if candidates.empty?
860
+
861
+ # Sort by distance
862
+ candidates.sort_by! { |c| levenshtein_distance(cmd, c) }
863
+ candidates.first(3)
864
+ end
720
865
  def hist_clean # Clean up @history
721
- @history.uniq!
722
866
  @history.compact!
723
867
  @history.delete("")
868
+
869
+ # Apply deduplication based on mode
870
+ case @history_dedup
871
+ when 'off'
872
+ # No deduplication
873
+ when 'full', 'smart'
874
+ # Remove duplicates, keeping first (most recent) occurrence
875
+ @history.uniq!
876
+ else
877
+ # Default to smart
878
+ @history.uniq!
879
+ end
880
+ end
881
+ def config(*args) # Configure rsh settings
882
+ setting = args[0]
883
+ value = args[1]
884
+
885
+ if setting.nil?
886
+ # Show current configuration
887
+ puts "\n Current Configuration:".c(@c_prompt).b
888
+ puts " history_dedup: #{@history_dedup}"
889
+ puts " session_autosave: #{@session_autosave}s #{@session_autosave > 0 ? '(enabled)' : '(disabled)'}"
890
+ puts " completion_limit: #{@completion_limit}"
891
+ puts " completion_fuzzy: #{@completion_fuzzy}"
892
+ puts " completion_case_sensitive: #{@completion_case_sensitive}"
893
+ puts
894
+ return
895
+ end
896
+
897
+ case setting
898
+ when 'history_dedup'
899
+ if %w[off full smart].include?(value)
900
+ @history_dedup = value
901
+ puts "History deduplication set to '#{value}'"
902
+ rshrc
903
+ else
904
+ puts "Invalid value. Use: off, full, or smart"
905
+ end
906
+ when 'session_autosave'
907
+ @session_autosave = value.to_i
908
+ puts "Session auto-save set to #{value}s #{value.to_i > 0 ? '(enabled)' : '(disabled)'}"
909
+ rshrc
910
+ when 'completion_limit'
911
+ @completion_limit = value.to_i
912
+ puts "Completion limit set to #{value}"
913
+ rshrc
914
+ else
915
+ puts "Unknown setting '#{setting}'"
916
+ puts "Available: history_dedup, session_autosave, completion_limit"
917
+ end
918
+ end
919
+ def env(*args) # Environment variable management
920
+ arg_str = args.join(' ')
921
+
922
+ if args.empty?
923
+ # List all environment variables
924
+ puts "\n Environment Variables:".c(@c_prompt).b
925
+ ENV.sort.first(20).each do |key, value|
926
+ value_display = value.length > 50 ? value[0..47] + '...' : value
927
+ puts " #{key.c(@c_gnick).ljust(25)} = #{value_display}"
928
+ end
929
+ puts " ... (#{ENV.length} total, showing first 20)"
930
+ puts "\n Use :env \"VARNAME\" to see specific variable"
931
+ puts
932
+ elsif arg_str =~ /^set\s+(\w+)\s+(.+)$/
933
+ # Set environment variable
934
+ var_name, var_value = $1, $2
935
+ ENV[var_name] = var_value
936
+ puts "#{var_name} = #{var_value}"
937
+ elsif arg_str =~ /^unset\s+(\w+)$/
938
+ # Unset environment variable
939
+ var_name = $1
940
+ ENV.delete(var_name)
941
+ puts "#{var_name} unset"
942
+ elsif arg_str =~ /^export\s+(.+)$/
943
+ # Export to shell script
944
+ filename = $1
945
+ File.write(filename, ENV.map { |k,v| "export #{k}=\"#{v}\"" }.join("\n"))
946
+ puts "Environment exported to #{filename}"
947
+ else
948
+ # Show specific variable
949
+ var_name = arg_str.strip
950
+ if ENV[var_name]
951
+ puts "#{var_name} = #{ENV[var_name]}"
952
+ else
953
+ puts "Environment variable '#{var_name}' not set"
954
+ end
955
+ end
724
956
  end
725
957
  def cmd_check(str) # Check if each element on the readline matches commands, nicks, paths; color them
726
958
  return if str.nil?
@@ -729,11 +961,19 @@ def cmd_check(str) # Check if each element on the readline matches commands, nic
729
961
  if str =~ /^(@@?)\s+(.*)$/
730
962
  prefix = $1
731
963
  rest = $2
732
- return prefix.c(4) + " " + rest # Color @ or @@ in blue (4), rest uncolored
964
+ return prefix.c(@c_colon) + " " + rest # Color @ or @@ in colon color
965
+ end
966
+
967
+ # Special handling for : commands
968
+ if str =~ /^(:[\w?_]+)/
969
+ colon_cmd = $1
970
+ rest = str.sub(/^:[\w?_]+/, '')
971
+ return colon_cmd.c(@c_colon) + rest # Color colon commands
733
972
  end
734
973
 
735
974
  str.gsub(/(?:\S'[^']*'|[^ '])+/) do |el|
736
975
  clean_el = el.gsub("'", "")
976
+ # Priority: commands > nicks > bookmarks > paths
737
977
  if @exe.include?(el)
738
978
  el.c(@c_cmd)
739
979
  elsif el == "cd"
@@ -741,8 +981,6 @@ def cmd_check(str) # Check if each element on the readline matches commands, nic
741
981
  elsif clean_el =~ /^\.\/(.+)/ && File.exist?(clean_el) && File.executable?(clean_el)
742
982
  # Color local executables starting with ./
743
983
  el.c(@c_cmd)
744
- elsif File.exist?(clean_el)
745
- el.c(@c_path)
746
984
  elsif @nick.include?(el)
747
985
  el.c(@c_nick)
748
986
  elsif el == "r" or el == "f"
@@ -751,6 +989,11 @@ def cmd_check(str) # Check if each element on the readline matches commands, nic
751
989
  el.c(@c_gnick)
752
990
  elsif self.respond_to?(el) && singleton_class.instance_methods(false).include?(el.to_sym)
753
991
  el.c(@c_nick).b # Ruby functions in bold nick color
992
+ elsif @bookmarks && @bookmarks.include?(el)
993
+ # Color bookmarks (after commands and nicks)
994
+ el.c(@c_bookmark)
995
+ elsif File.exist?(clean_el)
996
+ el.c(@c_path)
754
997
  elsif el[0] == "-"
755
998
  el.c(@c_switch)
756
999
  else
@@ -771,6 +1014,16 @@ def rshrc # Write updates to .rshrc
771
1014
  conf += "@gnick = #{@gnick}\n"
772
1015
  conf.sub!(/^@cmd_frequency.*(\n|$)/, "")
773
1016
  conf += "@cmd_frequency = #{@cmd_frequency}\n"
1017
+ conf.sub!(/^@cmd_stats.*(\n|$)/, "")
1018
+ conf += "@cmd_stats = #{@cmd_stats}\n" unless @cmd_stats.empty?
1019
+ conf.sub!(/^@bookmarks.*(\n|$)/, "")
1020
+ conf += "@bookmarks = #{@bookmarks}\n" unless @bookmarks.empty?
1021
+ conf.sub!(/^@defuns.*(\n|$)/, "")
1022
+ conf += "@defuns = #{@defuns}\n" unless @defuns.empty?
1023
+ conf.sub!(/^@history_dedup.*(\n|$)/, "")
1024
+ conf += "@history_dedup = '#{@history_dedup}'\n" if @history_dedup && @history_dedup != 'smart'
1025
+ conf.sub!(/^@session_autosave.*(\n|$)/, "")
1026
+ conf += "@session_autosave = #{@session_autosave}\n" if @session_autosave && @session_autosave > 0
774
1027
  # Only write @cmd_completions if user has customized it
775
1028
  unless conf =~ /^@cmd_completions\s*=/
776
1029
  # Don't write default completions to avoid cluttering .rshrc
@@ -807,6 +1060,7 @@ def help
807
1060
  left_col << "RIGHT/Ctrl-F Accept suggestion"
808
1061
  left_col << "UP/DOWN Navigate history"
809
1062
  left_col << "TAB Tab complete"
1063
+ left_col << "Shift-TAB Search history"
810
1064
  left_col << "Ctrl-Y Copy to clipboard"
811
1065
  left_col << "Ctrl-D Exit + save .rshrc"
812
1066
  left_col << "Ctrl-E Exit without save"
@@ -820,9 +1074,10 @@ def help
820
1074
  left_col << "SPECIAL COMMANDS:".c(@c_prompt).b
821
1075
  left_col << ":nick 'll = ls -l' Command alias"
822
1076
  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"
1077
+ left_col << ":nick List nicks"
1078
+ left_col << ":gnick List gnicks"
1079
+ left_col << ":nick '-name' Delete nick"
1080
+ left_col << ":gnick '-name' Delete gnick"
826
1081
  left_col << ":history Show history"
827
1082
  left_col << ":rmhistory Clear history"
828
1083
  left_col << ":info About rsh"
@@ -842,6 +1097,21 @@ def help
842
1097
  right_col << ":fg [id] Foreground job"
843
1098
  right_col << ":bg [id] Resume in bg"
844
1099
  right_col << ""
1100
+ right_col << "v3.0/3.1 FEATURES:".c(@c_prompt).b
1101
+ right_col << ":stats [--csv|--json] Analytics"
1102
+ right_col << ":bm \"name\" Create bookmark"
1103
+ right_col << "name Jump to bookmark"
1104
+ right_col << ":bm --stats Bookmark stats"
1105
+ right_col << ":bm --export file Export bookmarks"
1106
+ right_col << ":bm --import file Import bookmarks"
1107
+ right_col << ":save_session [nm] Save session"
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"
1114
+ right_col << ""
845
1115
  right_col << "INTEGRATIONS:".c(@c_prompt).b
846
1116
  right_col << "r Launch rtfm"
847
1117
  right_col << "f Launch fzf"
@@ -855,8 +1125,8 @@ def help
855
1125
  right_col << "SMART COMPLETIONS:".c(@c_prompt).b
856
1126
  right_col << "git <TAB> Git subcommands"
857
1127
  right_col << "apt/docker <TAB> Command options"
858
- right_col << "Supports: git, apt, docker,"
859
- right_col << "systemctl, cargo, npm, gem"
1128
+ right_col << "--format=<TAB> Option values"
1129
+ right_col << "Typo suggestions Auto-correct"
860
1130
  right_col << ""
861
1131
  right_col << "EXPANSIONS:".c(@c_prompt).b
862
1132
  right_col << "~ Home directory"
@@ -899,43 +1169,47 @@ def rmhistory # Delete history
899
1169
  @history = []
900
1170
  puts "History deleted."
901
1171
  end
902
- def nick(nick_str) # Define a new nick like this: `:nick "ls = ls --color"`
903
- if nick_str.match(/^\s*-/)
1172
+ def nick(nick_str = nil) # Define a new nick like this: `:nick "ls = ls --color"`
1173
+ if nick_str.nil? || nick_str.empty?
1174
+ # List all nicks
1175
+ puts "\n Command nicks:".c(@c_nick).b
1176
+ if @nick.empty?
1177
+ puts " (none defined)"
1178
+ else
1179
+ @nick.sort.each {|key, value| puts " #{key.c(@c_nick)} = #{value}"}
1180
+ end
1181
+ puts
1182
+ elsif nick_str.match(/^\s*-/)
904
1183
  source = nick_str.sub(/^\s*-/, '')
905
1184
  @nick.delete(source)
1185
+ rshrc
906
1186
  else
907
1187
  source = nick_str.sub(/ =.*/, '')
908
1188
  target = nick_str.sub(/.*= /, '')
909
1189
  @nick[source] = target
1190
+ rshrc
910
1191
  end
911
- rshrc
912
1192
  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*-/)
1193
+ def gnick(nick_str = nil) # Define a generic/global nick to match not only commands (format like nick)
1194
+ if nick_str.nil? || nick_str.empty?
1195
+ # List all gnicks
1196
+ puts "\n General nicks:".c(@c_gnick).b
1197
+ if @gnick.empty?
1198
+ puts " (none defined)"
1199
+ else
1200
+ @gnick.sort.each {|key, value| puts " #{key.c(@c_gnick)} = #{value}"}
1201
+ end
1202
+ puts
1203
+ elsif nick_str.match(/^\s*-/)
915
1204
  source = nick_str.sub(/^\s*-/, '')
916
1205
  @gnick.delete(source)
1206
+ rshrc
917
1207
  else
918
1208
  source = nick_str.sub(/ =.*/, '')
919
1209
  target = nick_str.sub(/.*= /, '')
920
1210
  @gnick[source] = target
1211
+ rshrc
921
1212
  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
1213
  end
940
1214
  def dirs
941
1215
  puts "Past direactories:"
@@ -994,6 +1268,7 @@ def defun(func_def) # Define a Ruby function like: `:defun "myls(*args) = Dir.g
994
1268
  func_name = func_def.sub(/^\s*-/, '')
995
1269
  if self.respond_to?(func_name)
996
1270
  singleton_class.remove_method(func_name.to_sym)
1271
+ @defuns.delete(func_name)
997
1272
  puts "Function '#{func_name}' removed"
998
1273
  else
999
1274
  puts "Function '#{func_name}' not found"
@@ -1005,11 +1280,12 @@ def defun(func_def) # Define a Ruby function like: `:defun "myls(*args) = Dir.g
1005
1280
  func_name = $1
1006
1281
  func_params = $2
1007
1282
  func_body = $3
1008
-
1283
+
1009
1284
  begin
1010
1285
  eval_code = "def #{func_name}(#{func_params}); #{func_body}; end"
1011
1286
  puts " DEBUG: Evaluating: #{eval_code}" if ENV['RSH_DEBUG']
1012
1287
  singleton_class.class_eval(eval_code)
1288
+ @defuns[func_name] = func_def # Store for persistence
1013
1289
  puts "Function '#{func_name}' defined"
1014
1290
  puts " DEBUG: Method created? #{respond_to?(func_name)}" if ENV['RSH_DEBUG']
1015
1291
  rescue SyntaxError => e
@@ -1038,6 +1314,464 @@ def defun? # Show all user-defined functions
1038
1314
  end
1039
1315
  end
1040
1316
  end
1317
+ def stats(*args) # Show command execution statistics and analytics
1318
+ format = args[0]
1319
+ filename = args[1]
1320
+
1321
+ if format == "--export"
1322
+ # Export to file
1323
+ fname = filename || "rsh_stats.json"
1324
+ export_stats(fname)
1325
+ return
1326
+ elsif format == "--json"
1327
+ fname = filename || "rsh_stats.json"
1328
+ export_stats_json(fname)
1329
+ return
1330
+ elsif format == "--csv"
1331
+ fname = filename || "rsh_stats.csv"
1332
+ export_stats_csv(fname)
1333
+ return
1334
+ end
1335
+
1336
+ # Display stats (existing code)
1337
+ puts "\n Command Execution Statistics".c(@c_prompt).b
1338
+ puts " " + "="*50
1339
+
1340
+ # Most used commands
1341
+ if @cmd_frequency && !@cmd_frequency.empty?
1342
+ puts "\n Top 10 Most Used Commands:".c(@c_nick)
1343
+ sorted = @cmd_frequency.sort_by { |_, count| -count }.first(10)
1344
+ sorted.each_with_index do |(cmd, count), i|
1345
+ bar = "■" * ([count / 5, 20].min)
1346
+ puts " #{(i+1).to_s.rjust(2)}. #{cmd.ljust(20)} #{count.to_s.rjust(5)}x #{bar.c(@c_path)}"
1347
+ end
1348
+ end
1349
+
1350
+ # Command statistics from @cmd_stats
1351
+ if @cmd_stats && !@cmd_stats.empty?
1352
+ total_time = @cmd_stats.values.map { |s| s[:total_time] || 0 }.sum
1353
+ total_cmds = @cmd_stats.values.map { |s| s[:count] || 0 }.sum
1354
+
1355
+ puts "\n Performance Statistics:".c(@c_nick)
1356
+ puts " Total commands executed: #{total_cmds}"
1357
+ puts " Total execution time: #{'%.2f' % total_time}s"
1358
+ puts " Average time per command: #{'%.2f' % (total_time / total_cmds)}s" if total_cmds > 0
1359
+
1360
+ puts "\n Slowest Commands:".c(@c_nick)
1361
+ slowest = @cmd_stats.sort_by { |_, s| -(s[:avg_time] || 0) }.first(5)
1362
+ slowest.each_with_index do |(cmd, stats), i|
1363
+ puts " #{(i+1).to_s.rjust(2)}. #{cmd.ljust(20)} avg: #{'%.3f' % (stats[:avg_time] || 0)}s"
1364
+ end
1365
+ end
1366
+
1367
+ # History statistics
1368
+ puts "\n History Statistics:".c(@c_nick)
1369
+ puts " Total history entries: #{@history.length}"
1370
+ puts " Unique commands: #{@history.uniq.length}"
1371
+
1372
+ # Success/failure tracking
1373
+ puts "\n Last command exit status: #{@last_exit == 0 ? 'Success'.c(@c_path) : "Failed (#{@last_exit})".c(196)}"
1374
+ puts
1375
+ end
1376
+ def export_stats(filename) # Export stats to file (JSON or CSV based on extension)
1377
+ if filename.end_with?('.csv')
1378
+ export_stats_csv(filename)
1379
+ else
1380
+ filename += '.json' unless filename.end_with?('.json')
1381
+ export_stats_json(filename)
1382
+ end
1383
+ end
1384
+ def export_stats_json(filename = 'rsh_stats.json') # Export stats to JSON
1385
+ stats_data = {
1386
+ generated: Time.now.to_i,
1387
+ cmd_frequency: @cmd_frequency,
1388
+ cmd_stats: @cmd_stats,
1389
+ history: {
1390
+ total: @history.length,
1391
+ unique: @history.uniq.length
1392
+ },
1393
+ last_exit: @last_exit
1394
+ }
1395
+ begin
1396
+ require 'json'
1397
+ File.write(filename, JSON.pretty_generate(stats_data))
1398
+ puts "Stats exported to #{filename}"
1399
+ rescue => e
1400
+ puts "Error exporting stats: #{e.message}"
1401
+ end
1402
+ end
1403
+ def export_stats_csv(filename = 'rsh_stats.csv') # Export stats to CSV
1404
+ begin
1405
+ lines = []
1406
+ lines << "command,frequency,count,total_time,avg_time"
1407
+
1408
+ # Merge frequency and performance data
1409
+ all_cmds = (@cmd_frequency.keys + @cmd_stats.keys).uniq
1410
+ all_cmds.sort.each do |cmd|
1411
+ freq = @cmd_frequency[cmd] || 0
1412
+ count = @cmd_stats.dig(cmd, :count) || 0
1413
+ total = @cmd_stats.dig(cmd, :total_time) || 0.0
1414
+ avg = @cmd_stats.dig(cmd, :avg_time) || 0.0
1415
+ lines << "#{cmd},#{freq},#{count},#{'%.3f' % total},#{'%.3f' % avg}"
1416
+ end
1417
+
1418
+ File.write(filename, lines.join("\n"))
1419
+ puts "Stats exported to #{filename}"
1420
+ rescue => e
1421
+ puts "Error exporting stats: #{e.message}"
1422
+ end
1423
+ end
1424
+ def bm(*args) # Enhanced bookmark management with tags
1425
+ # Handle variadic arguments
1426
+ arg_str = args.join(' ')
1427
+
1428
+ if args.empty?
1429
+ # List all bookmarks
1430
+ if @bookmarks.empty?
1431
+ puts "No bookmarks defined. Use :bm \"name\" to bookmark current directory"
1432
+ return
1433
+ end
1434
+ puts "\n Bookmarks:".c(@c_prompt).b
1435
+ @bookmarks.each do |name, data|
1436
+ path = data.is_a?(Hash) ? data[:path] : data
1437
+ tags = data.is_a?(Hash) && data[:tags] ? " [#{data[:tags].join(', ')}]" : ""
1438
+ puts " #{name.c(@c_nick)} → #{path}#{tags.c(@c_stamp)}"
1439
+ end
1440
+ puts
1441
+ elsif args[0] == '--export'
1442
+ # Export bookmarks to file
1443
+ filename = args[1] || 'bookmarks.json'
1444
+ export_bookmarks(filename)
1445
+ elsif args[0] == '--import'
1446
+ # Import bookmarks from file
1447
+ filename = args[1]
1448
+ import_bookmarks(filename) if filename
1449
+ elsif args[0] == '--stats'
1450
+ # Show bookmark statistics
1451
+ bookmark_stats
1452
+ elsif arg_str =~ /^(\w+)\s+(.+)$/
1453
+ # Set bookmark with optional tags
1454
+ name, rest = $1, $2
1455
+ if rest.include?('#')
1456
+ path_part, tag_part = rest.split('#', 2)
1457
+ path = path_part.strip
1458
+ path = Dir.pwd if path.empty?
1459
+ tags = tag_part.split(',').map(&:strip)
1460
+ @bookmarks[name] = {path: path, tags: tags}
1461
+ else
1462
+ @bookmarks[name] = {path: rest.strip, tags: []}
1463
+ end
1464
+ puts "Bookmark '#{name}' set to #{@bookmarks[name][:path]}"
1465
+ rshrc
1466
+ elsif arg_str =~ /^-(\w+)$/
1467
+ # Delete bookmark
1468
+ name = $1
1469
+ if @bookmarks.delete(name)
1470
+ puts "Bookmark '#{name}' deleted"
1471
+ rshrc
1472
+ else
1473
+ puts "Bookmark '#{name}' not found"
1474
+ end
1475
+ elsif arg_str =~ /^\?(\w*)$/
1476
+ # Search bookmarks by tag
1477
+ tag = $1
1478
+ if tag.empty?
1479
+ puts "Available tags:"
1480
+ all_tags = @bookmarks.values.flat_map { |d| d.is_a?(Hash) ? d[:tags] : [] }.uniq.sort
1481
+ puts " " + all_tags.join(", ")
1482
+ else
1483
+ matches = @bookmarks.select do |_, data|
1484
+ data.is_a?(Hash) && data[:tags] && data[:tags].include?(tag)
1485
+ end
1486
+ if matches.empty?
1487
+ puts "No bookmarks with tag '#{tag}'"
1488
+ else
1489
+ puts "Bookmarks with tag '#{tag}':"
1490
+ matches.each { |name, data| puts " #{name} → #{data[:path]}" }
1491
+ end
1492
+ end
1493
+ else
1494
+ # Bookmark current directory
1495
+ name = arg_str.strip
1496
+ @bookmarks[name] = {path: Dir.pwd, tags: []}
1497
+ puts "Bookmark '#{name}' set to #{Dir.pwd}"
1498
+ rshrc
1499
+ end
1500
+ end
1501
+ def bookmark(*args) # Alias for bm
1502
+ bm(*args)
1503
+ end
1504
+ def export_bookmarks(filename = 'bookmarks.json') # Export bookmarks to JSON
1505
+ begin
1506
+ require 'json'
1507
+ File.write(filename, JSON.pretty_generate(@bookmarks))
1508
+ puts "Bookmarks exported to #{filename}"
1509
+ rescue => e
1510
+ puts "Error exporting bookmarks: #{e.message}"
1511
+ end
1512
+ end
1513
+ def import_bookmarks(filename) # Import bookmarks from JSON
1514
+ unless File.exist?(filename)
1515
+ puts "File '#{filename}' not found"
1516
+ return
1517
+ end
1518
+ begin
1519
+ require 'json'
1520
+ imported = JSON.parse(File.read(filename))
1521
+ imported.each do |name, data|
1522
+ # Convert to proper format
1523
+ if data.is_a?(Hash)
1524
+ @bookmarks[name] = {
1525
+ path: data['path'] || data[:path],
1526
+ tags: data['tags'] || data[:tags] || []
1527
+ }.transform_keys(&:to_sym)
1528
+ else
1529
+ @bookmarks[name] = {path: data.to_s, tags: []}
1530
+ end
1531
+ end
1532
+ puts "Imported #{imported.length} bookmarks from #{filename}"
1533
+ rshrc
1534
+ rescue => e
1535
+ puts "Error importing bookmarks: #{e.message}"
1536
+ end
1537
+ end
1538
+ def bookmark_stats # Show bookmark usage statistics
1539
+ if @bookmarks.empty?
1540
+ puts "No bookmarks defined"
1541
+ return
1542
+ end
1543
+
1544
+ puts "\n Bookmark Statistics".c(@c_prompt).b
1545
+ puts " " + "="*50
1546
+ puts "\n Total bookmarks: #{@bookmarks.length}"
1547
+
1548
+ # Count by tags
1549
+ all_tags = @bookmarks.values.flat_map { |d| d.is_a?(Hash) ? (d[:tags] || []) : [] }
1550
+ unless all_tags.empty?
1551
+ puts "\n Tags Distribution:".c(@c_nick)
1552
+ tag_counts = all_tags.group_by(&:itself).transform_values(&:count)
1553
+ tag_counts.sort_by { |_, count| -count }.each do |tag, count|
1554
+ puts " #{tag.ljust(15)} #{count}x"
1555
+ end
1556
+ end
1557
+
1558
+ # Bookmarks by directory depth
1559
+ puts "\n Path Analysis:".c(@c_nick)
1560
+ paths = @bookmarks.values.map { |d| d.is_a?(Hash) ? d[:path] : d }
1561
+ avg_depth = paths.map { |p| p.split('/').length }.sum / paths.length
1562
+ puts " Average path depth: #{avg_depth}"
1563
+ puts
1564
+ end
1565
+ def save_session(*args) # Save current session state
1566
+ session_name = args[0] || 'default'
1567
+ session_path = @session_dir + "/#{session_name}.json"
1568
+
1569
+ session = {
1570
+ name: session_name,
1571
+ pwd: Dir.pwd,
1572
+ history: @history.first(50),
1573
+ bookmarks: @bookmarks,
1574
+ defuns: @defuns,
1575
+ timestamp: Time.now.to_i
1576
+ }
1577
+ begin
1578
+ require 'json'
1579
+ File.write(session_path, JSON.pretty_generate(session))
1580
+ puts "Session '#{session_name}' saved to #{session_path}"
1581
+ rescue => e
1582
+ puts "Error saving session: #{e.message}"
1583
+ end
1584
+ end
1585
+ def load_session(*args) # Restore previous session
1586
+ session_name = args[0] || 'default'
1587
+ session_path = @session_dir + "/#{session_name}.json"
1588
+
1589
+ unless File.exist?(session_path)
1590
+ puts "Session '#{session_name}' not found"
1591
+ list_sessions
1592
+ return
1593
+ end
1594
+ begin
1595
+ require 'json'
1596
+ session = JSON.parse(File.read(session_path), symbolize_names: true)
1597
+
1598
+ # Restore state
1599
+ Dir.chdir(session[:pwd]) if session[:pwd] && Dir.exist?(session[:pwd])
1600
+
1601
+ # Merge history (prepend saved history)
1602
+ if session[:history]
1603
+ @history = (session[:history] + @history).uniq.first(@histsize)
1604
+ end
1605
+
1606
+ # Restore bookmarks
1607
+ if session[:bookmarks]
1608
+ session[:bookmarks].each do |name, data|
1609
+ bookmark_data = data.is_a?(Hash) ? data.transform_keys(&:to_sym) : data
1610
+ @bookmarks[name.to_s] = bookmark_data
1611
+ end
1612
+ end
1613
+
1614
+ # Restore defuns
1615
+ if session[:defuns]
1616
+ session[:defuns].each do |name, func_def|
1617
+ next unless func_def.is_a?(String)
1618
+ @defuns[name.to_s] = func_def
1619
+ # Re-evaluate the function
1620
+ if func_def =~ /^(\w+)\s*\(([^)]*)\)\s*=\s*(.+)$/
1621
+ func_name, func_params, func_body = $1, $2, $3
1622
+ eval_code = "def #{func_name}(#{func_params}); #{func_body}; end"
1623
+ singleton_class.class_eval(eval_code) rescue nil
1624
+ end
1625
+ end
1626
+ end
1627
+
1628
+ saved_time = Time.at(session[:timestamp] || 0).strftime("%Y-%m-%d %H:%M:%S")
1629
+ puts "Session '#{session_name}' restored from #{saved_time}"
1630
+ rshrc
1631
+ rescue => e
1632
+ puts "Error loading session: #{e.message}"
1633
+ end
1634
+ end
1635
+ def list_sessions # List all saved sessions
1636
+ unless Dir.exist?(@session_dir)
1637
+ puts "No sessions directory found"
1638
+ return
1639
+ end
1640
+
1641
+ sessions = Dir.glob(@session_dir + '/*.json').map { |f| File.basename(f, '.json') }
1642
+
1643
+ if sessions.empty?
1644
+ puts "No saved sessions found. Use :save_session \"name\" to create one"
1645
+ return
1646
+ end
1647
+
1648
+ puts "\n Saved Sessions:".c(@c_prompt).b
1649
+ sessions.sort.each do |name|
1650
+ session_path = @session_dir + "/#{name}.json"
1651
+ begin
1652
+ require 'json'
1653
+ session = JSON.parse(File.read(session_path), symbolize_names: true)
1654
+ timestamp = Time.at(session[:timestamp] || 0).strftime("%Y-%m-%d %H:%M")
1655
+ pwd = session[:pwd] || '?'
1656
+ puts " #{name.c(@c_bookmark).ljust(20)} #{timestamp.c(@c_stamp)} #{pwd.c(@c_path)}"
1657
+ rescue => e
1658
+ puts " #{name.c(@c_bookmark).ljust(20)} [corrupted]".c(196)
1659
+ end
1660
+ end
1661
+ puts
1662
+ end
1663
+ def delete_session(*args) # Delete a saved session
1664
+ name = args[0]
1665
+
1666
+ if name == '*'
1667
+ # Delete all sessions except default
1668
+ sessions = Dir.glob(@session_dir + '/*.json').map { |f| File.basename(f, '.json') }
1669
+ sessions.reject! { |s| s == 'default' || s == 'autosave' }
1670
+
1671
+ if sessions.empty?
1672
+ puts "No sessions to delete (keeping default and autosave)"
1673
+ return
1674
+ end
1675
+
1676
+ sessions.each do |session_name|
1677
+ session_path = @session_dir + "/#{session_name}.json"
1678
+ File.delete(session_path)
1679
+ end
1680
+ puts "Deleted #{sessions.length} sessions: #{sessions.join(', ')}"
1681
+ return
1682
+ end
1683
+
1684
+ return puts "Cannot delete default session" if name == 'default'
1685
+ return puts "Cannot delete autosave session (use * to delete all)" if name == 'autosave'
1686
+
1687
+ session_path = @session_dir + "/#{name}.json"
1688
+ unless File.exist?(session_path)
1689
+ puts "Session '#{name}' not found"
1690
+ return
1691
+ end
1692
+
1693
+ File.delete(session_path)
1694
+ puts "Session '#{name}' deleted"
1695
+ end
1696
+ def rmsession(*args) # Alias for delete_session
1697
+ delete_session(*args)
1698
+ end
1699
+ def theme(*args) # Apply color scheme presets
1700
+ name = args[0]
1701
+
1702
+ if name.nil?
1703
+ puts "\n Available themes:".c(@c_prompt).b
1704
+ puts " default, solarized, dracula, gruvbox, nord, monokai"
1705
+ puts "\n Current theme colors:"
1706
+ puts " prompt:#{' '*5}#{@c_prompt} cmd:#{' '*8}#{@c_cmd} nick:#{' '*7}#{@c_nick}"
1707
+ puts " gnick:#{' '*6}#{@c_gnick} path:#{' '*7}#{@c_path} switch:#{' '*5}#{@c_switch}"
1708
+ puts " bookmark:#{' '*3}#{@c_bookmark} colon:#{' '*6}#{@c_colon} tabselect:#{' '*2}#{@c_tabselect}"
1709
+ puts " taboption:#{' '*2}#{@c_taboption} stamp:#{' '*6}#{@c_stamp}"
1710
+ puts
1711
+ return
1712
+ end
1713
+
1714
+ case name.downcase
1715
+ when 'default'
1716
+ @c_prompt, @c_cmd, @c_nick, @c_gnick = 10, 2, 6, 14
1717
+ @c_path, @c_switch, @c_bookmark, @c_colon = 3, 6, 13, 4
1718
+ @c_tabselect, @c_taboption, @c_stamp = 5, 244, 244
1719
+ when 'solarized'
1720
+ @c_prompt, @c_cmd, @c_nick, @c_gnick = 33, 64, 37, 117
1721
+ @c_path, @c_switch, @c_bookmark, @c_colon = 136, 125, 61, 33
1722
+ @c_tabselect, @c_taboption, @c_stamp = 166, 240, 240
1723
+ when 'dracula'
1724
+ @c_prompt, @c_cmd, @c_nick, @c_gnick = 141, 84, 117, 212
1725
+ @c_path, @c_switch, @c_bookmark, @c_colon = 228, 215, 141, 141
1726
+ @c_tabselect, @c_taboption, @c_stamp = 212, 238, 238
1727
+ when 'gruvbox'
1728
+ @c_prompt, @c_cmd, @c_nick, @c_gnick = 214, 142, 109, 175
1729
+ @c_path, @c_switch, @c_bookmark, @c_colon = 208, 142, 167, 214
1730
+ @c_tabselect, @c_taboption, @c_stamp = 208, 243, 243
1731
+ when 'nord'
1732
+ @c_prompt, @c_cmd, @c_nick, @c_gnick = 110, 109, 116, 152
1733
+ @c_path, @c_switch, @c_bookmark, @c_colon = 180, 109, 139, 110
1734
+ @c_tabselect, @c_taboption, @c_stamp = 143, 240, 240
1735
+ when 'monokai'
1736
+ @c_prompt, @c_cmd, @c_nick, @c_gnick = 197, 112, 81, 141
1737
+ @c_path, @c_switch, @c_bookmark, @c_colon = 228, 208, 141, 197
1738
+ @c_tabselect, @c_taboption, @c_stamp = 197, 238, 238
1739
+ else
1740
+ puts "Unknown theme '#{name}'. Available: default, solarized, dracula, gruvbox, nord, monokai"
1741
+ return
1742
+ end
1743
+
1744
+ puts "Theme '#{name}' applied"
1745
+ puts "Add this to .rshrc to make it permanent: :theme \"#{name}\""
1746
+ end
1747
+ def validate_command(cmd) # Syntax validation before execution
1748
+ return nil if cmd.nil? || cmd.empty?
1749
+ warnings = []
1750
+
1751
+ # Check for common mistakes
1752
+ warnings << "Unmatched quotes" if cmd.count("'").odd? || cmd.count('"').odd?
1753
+ warnings << "Unmatched parentheses" if cmd.count("(") != cmd.count(")")
1754
+ warnings << "Unmatched brackets" if cmd.count("[") != cmd.count("]")
1755
+ warnings << "Unmatched braces" if cmd.count("{") != cmd.count("}")
1756
+
1757
+ # Check for potentially dangerous patterns
1758
+ warnings << "WARNING: Recursive rm detected" if cmd =~ /rm\s+.*-r.*\//
1759
+ warnings << "WARNING: Force flag without path" if cmd =~ /rm\s+-[rf]+\s*$/
1760
+ warnings << "WARNING: Sudo with redirection" if cmd =~ /sudo.*>/
1761
+
1762
+ # Check for common typos in popular commands
1763
+ if cmd =~ /^(\w+)/
1764
+ first_cmd = $1
1765
+ unless @exe.include?(first_cmd) || @nick.include?(first_cmd) || first_cmd == "cd"
1766
+ suggestions = suggest_command(first_cmd)
1767
+ if suggestions && !suggestions.empty?
1768
+ warnings << "Command '#{first_cmd}' not found. Did you mean: #{suggestions.join(', ')}?"
1769
+ end
1770
+ end
1771
+ end
1772
+
1773
+ warnings.empty? ? nil : warnings
1774
+ end
1041
1775
  def execute_conditional(cmd_line)
1042
1776
  # Split on && and || while preserving the operators
1043
1777
  parts = cmd_line.split(/(\s*&&\s*|\s*\|\|\s*)/)
@@ -1316,6 +2050,27 @@ def load_rshrc_safe
1316
2050
  @nick = {} unless @nick.is_a?(Hash)
1317
2051
  @gnick = {} unless @gnick.is_a?(Hash)
1318
2052
  @cmd_frequency = {} unless @cmd_frequency.is_a?(Hash)
2053
+ @cmd_stats = {} unless @cmd_stats.is_a?(Hash)
2054
+ @bookmarks = {} unless @bookmarks.is_a?(Hash)
2055
+ @defuns = {} unless @defuns.is_a?(Hash)
2056
+ @history_dedup = 'smart' unless @history_dedup.is_a?(String)
2057
+ @session_autosave = 0 unless @session_autosave.is_a?(Integer)
2058
+
2059
+ # Restore defuns from .rshrc
2060
+ if @defuns && !@defuns.empty?
2061
+ @defuns.each do |name, func_def|
2062
+ next unless func_def.is_a?(String)
2063
+ if func_def =~ /^(\w+)\s*\(([^)]*)\)\s*=\s*(.+)$/
2064
+ func_name, func_params, func_body = $1, $2, $3
2065
+ begin
2066
+ eval_code = "def #{func_name}(#{func_params}); #{func_body}; end"
2067
+ singleton_class.class_eval(eval_code)
2068
+ rescue => e
2069
+ puts "Warning: Could not load defun '#{name}': #{e.message}" if ENV['RSH_DEBUG']
2070
+ end
2071
+ end
2072
+ end
2073
+ end
1319
2074
 
1320
2075
  rescue SyntaxError => e
1321
2076
  puts "\n\033[31mERROR: Syntax error in .rshrc:\033[0m"
@@ -1429,6 +2184,13 @@ def load_defaults
1429
2184
  @completion_show_descriptions ||= false
1430
2185
  @completion_fuzzy ||= true
1431
2186
  @cmd_frequency ||= {}
2187
+ @cmd_stats ||= {}
2188
+ @bookmarks ||= {}
2189
+ @defuns ||= {}
2190
+ @switch_cache ||= {}
2191
+ @switch_cache_time ||= {}
2192
+ @history_dedup ||= 'smart'
2193
+ @session_autosave ||= 0
1432
2194
  puts "Loaded with default configuration."
1433
2195
  end
1434
2196
 
@@ -1509,11 +2271,19 @@ loop do
1509
2271
  begin
1510
2272
  @user = Etc.getpwuid(Process.euid).name # For use in @prompt
1511
2273
  @node = Etc.uname[:nodename] # For use in @prompt
1512
- h = @history; load_rshrc_safe; @history = h # reload prompt but not history safely
2274
+ 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
2275
  @prompt.gsub!(/#{Dir.home}/, '~') # Simplify path in prompt
1514
2276
  system("printf \"\033]0;rsh: #{Dir.pwd}\007\"") # Set Window title to path
1515
2277
  @history[0] = "" unless @history[0]
1516
2278
  cache_executables # Use cached executable lookup
2279
+ # Auto-save session if enabled and interval elapsed
2280
+ if @session_autosave && @session_autosave > 0
2281
+ current_time = Time.now.to_i
2282
+ if (current_time - @session_last_save) >= @session_autosave
2283
+ save_session('autosave')
2284
+ @session_last_save = current_time
2285
+ end
2286
+ end
1517
2287
  getstr # Main work is here
1518
2288
  @cmd = @history[0]
1519
2289
  @dirs.unshift(Dir.pwd)
@@ -1609,11 +2379,23 @@ loop do
1609
2379
  @cmd = Dir.home if @cmd == "~"
1610
2380
  @cmd = @dirs[1] if @cmd == "-"
1611
2381
  @cmd = @dirs[@cmd.to_i] if @cmd =~ /^\d$/
1612
- # Check if it's a directory to change to
2382
+ # Check if it's a directory to change to first
1613
2383
  dir = @cmd.strip.sub(/~/, Dir.home)
1614
2384
  if Dir.exist?(dir)
1615
- Dir.chdir(dir)
2385
+ Dir.chdir(dir)
1616
2386
  system("git status .") if Dir.exist?(".git")
2387
+ # Then check if it's a bookmark (commands and nicks already handled above)
2388
+ elsif @bookmarks && @bookmarks[@cmd]
2389
+ bookmark_data = @bookmarks[@cmd]
2390
+ bm_dir = bookmark_data.is_a?(Hash) ? bookmark_data[:path] : bookmark_data
2391
+ bm_dir = bm_dir.sub(/^~/, Dir.home) # Expand tilde
2392
+ if Dir.exist?(bm_dir)
2393
+ Dir.chdir(bm_dir)
2394
+ puts "Jumped to bookmark '#{@cmd}' → #{bm_dir}".c(@c_path)
2395
+ system("git status .") if Dir.exist?(".git")
2396
+ else
2397
+ puts "Bookmark '#{@cmd}' points to non-existent directory: #{bm_dir}".c(196)
2398
+ end
1617
2399
  else
1618
2400
  puts "#{Time.now.strftime("%H:%M:%S")}: #{@cmd}".c(@c_stamp)
1619
2401
  if @cmd == "f" # fzf integration (https://github.com/junegunn/fzf)
@@ -1630,16 +2412,34 @@ loop do
1630
2412
  Thread.new { system("xdg-open #{@cmd} 2>/dev/null") }
1631
2413
  end
1632
2414
  end
1633
- else
2415
+ else
1634
2416
  begin
2417
+ # Validate command before execution
2418
+ warnings = validate_command(@cmd)
2419
+ if warnings && !warnings.empty?
2420
+ warnings.each { |w| puts "#{w}".c(196) }
2421
+ # For critical warnings, ask for confirmation
2422
+ if warnings.any? { |w| w.start_with?("WARNING:") }
2423
+ print "Continue anyway? (y/N): "
2424
+ response = $stdin.gets.chomp
2425
+ unless response.downcase == 'y'
2426
+ puts "Command cancelled"
2427
+ next
2428
+ end
2429
+ end
2430
+ end
2431
+
1635
2432
  pre_cmd
1636
2433
 
1637
2434
  # 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
2435
+ cmd_base = @cmd.split.first if @cmd && !@cmd.empty?
2436
+ if cmd_base
2437
+ @cmd_frequency[cmd_base] = (@cmd_frequency[cmd_base] || 0) + 1
1641
2438
  end
1642
2439
 
2440
+ # Start timing
2441
+ start_time = Time.now
2442
+
1643
2443
  # Handle background jobs
1644
2444
  if @cmd.end_with?(' &')
1645
2445
  @cmd = @cmd[0..-3] # Remove the &
@@ -1653,13 +2453,23 @@ loop do
1653
2453
  @jobs[@job_id] = {pid: pid, cmd: @cmd, status: :running}
1654
2454
  puts "[#{@job_id}] #{pid} #{@cmd}"
1655
2455
  else
1656
- # Better handling of pipes and redirections
2456
+ # Better handling of pipes and redirections
1657
2457
  @current_pid = spawn(@cmd)
1658
2458
  Process.wait(@current_pid)
1659
2459
  @last_exit = $?.exitstatus
1660
2460
  @current_pid = nil
1661
2461
  puts " Command failed: #{@cmd} (exit #{@last_exit})" unless @last_exit == 0
1662
2462
  end
2463
+
2464
+ # Track execution time
2465
+ elapsed = Time.now - start_time
2466
+ if cmd_base && elapsed > 0.01 # Only track if > 10ms
2467
+ @cmd_stats[cmd_base] ||= {count: 0, total_time: 0.0, avg_time: 0.0}
2468
+ @cmd_stats[cmd_base][:count] += 1
2469
+ @cmd_stats[cmd_base][:total_time] += elapsed
2470
+ @cmd_stats[cmd_base][:avg_time] = @cmd_stats[cmd_base][:total_time] / @cmd_stats[cmd_base][:count]
2471
+ end
2472
+
1663
2473
  post_cmd
1664
2474
  rescue StandardError => err
1665
2475
  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.1.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.1.0: Multiple named sessions, stats export (CSV/JSON), session auto-save, bookmark
16
+ import/export, bookmark statistics, 6 color themes, config management, environment
17
+ variable tools, bookmark TAB completion, and much more!'
17
18
  email: g@isene.com
18
19
  executables:
19
20
  - rsh