srsh 0.7.0 → 0.8.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.
data/bin/srsh DELETED
@@ -1,1224 +0,0 @@
1
- #!/usr/bin/env ruby
2
- require 'shellwords'
3
- require 'socket'
4
- require 'time'
5
- require 'etc'
6
- require 'rbconfig'
7
- require 'io/console'
8
-
9
- # ---------------- Version ----------------
10
- SRSH_VERSION = "0.7.0"
11
-
12
- $0 = "srsh-#{SRSH_VERSION}"
13
- ENV['SHELL'] = "srsh-#{SRSH_VERSION}"
14
- print "\033]0;srsh-#{SRSH_VERSION}\007"
15
-
16
- Dir.chdir(ENV['HOME']) if ENV['HOME']
17
-
18
- $child_pids = []
19
- $aliases = {}
20
- $last_render_rows = 0
21
-
22
- $last_status = 0
23
- $rsh_functions = {}
24
- $rsh_positional = {}
25
- $rsh_script_mode = false
26
-
27
- Signal.trap("INT", "IGNORE")
28
-
29
- # ---------------- History ----------------
30
- HISTORY_FILE = File.join(Dir.home, ".srsh_history")
31
- HISTORY = if File.exist?(HISTORY_FILE)
32
- File.readlines(HISTORY_FILE, chomp: true)
33
- else
34
- []
35
- end
36
-
37
- at_exit do
38
- begin
39
- File.open(HISTORY_FILE, "w") do |f|
40
- HISTORY.each { |line| f.puts line }
41
- end
42
- rescue
43
- end
44
- end
45
-
46
- # ---------------- RC file (create if missing) ----------------
47
- RC_FILE = File.join(Dir.home, ".srshrc")
48
- begin
49
- unless File.exist?(RC_FILE)
50
- File.write(RC_FILE, <<~RC)
51
- # ~/.srshrc — srsh configuration
52
- # This file was created automatically by srsh v#{SRSH_VERSION}.
53
- # You can keep personal notes or planned settings here.
54
- # (Currently not sourced by srsh runtime.)
55
- RC
56
- end
57
- rescue
58
- end
59
-
60
- # ---------------- Utilities ----------------
61
- def color(text, code)
62
- "\e[#{code}m#{text}\e[0m"
63
- end
64
-
65
- def random_color
66
- [31, 32, 33, 34, 35, 36, 37].sample
67
- end
68
-
69
- def rainbow_codes
70
- [31, 33, 32, 36, 34, 35, 91, 93, 92, 96, 94, 95]
71
- end
72
-
73
- # variable expansion: $VAR and $1, $2, $0 (script/function args)
74
- def expand_vars(str)
75
- s = str.gsub(/\$([a-zA-Z_][a-zA-Z0-9_]*)/) do
76
- ENV[$1] || ""
77
- end
78
- s.gsub(/\$(\d+)/) do
79
- idx = $1.to_i
80
- ($rsh_positional && $rsh_positional[idx]) || ""
81
- end
82
- end
83
-
84
- def parse_redirection(cmd)
85
- stdin_file = nil
86
- stdout_file = nil
87
- append = false
88
-
89
- if cmd =~ /(.*)>>\s*(\S+)/
90
- cmd = $1.strip
91
- stdout_file = $2.strip
92
- append = true
93
- elsif cmd =~ /(.*)>\s*(\S+)/
94
- cmd = $1.strip
95
- stdout_file = $2.strip
96
- end
97
-
98
- if cmd =~ /(.*)<\s*(\S+)/
99
- cmd = $1.strip
100
- stdin_file = $2.strip
101
- end
102
-
103
- [cmd, stdin_file, stdout_file, append]
104
- end
105
-
106
- def human_bytes(bytes)
107
- units = ['B', 'KB', 'MB', 'GB', 'TB']
108
- size = bytes.to_f
109
- unit = units.shift
110
- while size > 1024 && !units.empty?
111
- size /= 1024
112
- unit = units.shift
113
- end
114
- "#{format('%.2f', size)} #{unit}"
115
- end
116
-
117
- def nice_bar(p, w = 30, code = 32)
118
- p = [[p, 0.0].max, 1.0].min
119
- f = (p * w).round
120
- b = "█" * f + "░" * (w - f)
121
- pct = (p * 100).to_i
122
- "#{color("[#{b}]", code)} #{color(sprintf("%3d%%", pct), 37)}"
123
- end
124
-
125
- def terminal_width
126
- IO.console.winsize[1]
127
- rescue
128
- 80
129
- end
130
-
131
- def strip_ansi(str)
132
- str.to_s.gsub(/\e\[[0-9;]*m/, '')
133
- end
134
-
135
- # ---------------- Aliases ----------------
136
- def expand_aliases(cmd, seen = [])
137
- return cmd if cmd.nil? || cmd.strip.empty?
138
- first_word, rest = cmd.strip.split(' ', 2)
139
- return cmd if seen.include?(first_word)
140
- seen << first_word
141
-
142
- if $aliases.key?(first_word)
143
- replacement = $aliases[first_word]
144
- expanded = expand_aliases(replacement, seen)
145
- rest ? "#{expanded} #{rest}" : expanded
146
- else
147
- cmd
148
- end
149
- end
150
-
151
- # ---------------- System Info ----------------
152
- def current_time
153
- Time.now.strftime("%Y-%m-%d %H:%M:%S %Z")
154
- end
155
-
156
- def detect_distro
157
- if File.exist?('/etc/os-release')
158
- line = File.read('/etc/os-release').lines.find { |l|
159
- l.start_with?('PRETTY_NAME="') || l.start_with?('PRETTY_NAME=')
160
- }
161
- return line.split('=').last.strip.delete('"') if line
162
- end
163
- "#{RbConfig::CONFIG['host_os']}"
164
- end
165
-
166
- def os_type
167
- host = RbConfig::CONFIG['host_os'].to_s
168
- case host
169
- when /linux/i
170
- :linux
171
- when /darwin/i
172
- :mac
173
- when /bsd/i
174
- :bsd
175
- else
176
- :other
177
- end
178
- end
179
-
180
- # ---------------- Quotes ----------------
181
- QUOTES = [
182
- "Keep calm and code on.",
183
- "Did you try turning it off and on again?",
184
- "There’s no place like 127.0.0.1.",
185
- "To iterate is human, to recurse divine.",
186
- "sudo rm -rf / – Just kidding, don’t do that!",
187
- "The shell is mightier than the sword.",
188
- "A journey of a thousand commits begins with a single push.",
189
- "In case of fire: git commit, git push, leave building.",
190
- "Debugging is like being the detective in a crime movie where you are also the murderer.",
191
- "Unix is user-friendly. It's just selective about who its friends are.",
192
- "Old sysadmins never die, they just become daemons.",
193
- "Listen you flatpaker! – Totally Terry Davis",
194
- "How is #{detect_distro}? 🤔",
195
- "Life is short, but your command history is eternal.",
196
- "If at first you don’t succeed, git commit and push anyway.",
197
- "rm -rf: the ultimate trust exercise.",
198
- "Coding is like magic, but with more coffee.",
199
- "There’s no bug, only undocumented features.",
200
- "Keep your friends close and your aliases closer.",
201
- "Why wait for the future when you can Ctrl+Z it?",
202
- "A watched process never completes.",
203
- "When in doubt, make it a function.",
204
- "Some call it procrastination, we call it debugging curiosity.",
205
- "Life is like a terminal; some commands just don’t execute.",
206
- "Good code is like a good joke; it needs no explanation.",
207
- "sudo: because sometimes responsibility is overrated.",
208
- "Pipes make the world go round.",
209
- "In bash we trust, in Ruby we wonder.",
210
- "A system without errors is like a day without coffee.",
211
- "Keep your loops tight and your sleeps short.",
212
- "Stack traces are just life giving you directions.",
213
- "Your mom called, she wants her semicolons back."
214
- ]
215
-
216
- $current_quote = QUOTES.sample
217
-
218
- def dynamic_quote
219
- chars = $current_quote.chars
220
- rainbow = rainbow_codes.cycle
221
- chars.map { |c| color(c, rainbow.next) }.join
222
- end
223
-
224
- # ---------------- CPU / RAM / Storage ----------------
225
- def read_cpu_times
226
- return [] unless File.exist?('/proc/stat')
227
- cpu_line = File.readlines('/proc/stat').find { |line| line.start_with?('cpu ') }
228
- return [] unless cpu_line
229
- cpu_line.split[1..-1].map(&:to_i)
230
- end
231
-
232
- def calculate_cpu_usage(prev, current)
233
- return 0.0 if prev.empty? || current.empty?
234
- prev_idle = prev[3] + (prev[4] || 0)
235
- idle = current[3] + (current[4] || 0)
236
- prev_non_idle = prev[0] + prev[1] + prev[2] +
237
- (prev[5] || 0) + (prev[6] || 0) + (prev[7] || 0)
238
- non_idle = current[0] + current[1] + current[2] +
239
- (current[5] || 0) + (current[6] || 0) + (current[7] || 0)
240
- prev_total = prev_idle + prev_non_idle
241
- total = idle + non_idle
242
- totald = total - prev_total
243
- idled = idle - prev_idle
244
- return 0.0 if totald <= 0
245
- ((totald - idled).to_f / totald) * 100
246
- end
247
-
248
- def cpu_cores_and_freq
249
- return [0, []] unless File.exist?('/proc/cpuinfo')
250
- cores = 0
251
- freqs = []
252
- File.foreach('/proc/cpuinfo') do |line|
253
- cores += 1 if line =~ /^processor\s*:\s*\d+/
254
- if line =~ /^cpu MHz\s*:\s*([\d.]+)/
255
- freqs << $1.to_f
256
- end
257
- end
258
- [cores, freqs.first(cores)]
259
- end
260
-
261
- def cpu_info
262
- usage = 0.0
263
- cores = 0
264
- freq_display = "N/A"
265
-
266
- case os_type
267
- when :linux
268
- prev = read_cpu_times
269
- sleep 0.05
270
- current = read_cpu_times
271
- usage = calculate_cpu_usage(prev, current).round(1)
272
- cores, freqs = cpu_cores_and_freq
273
- freq_display = freqs.empty? ? "N/A" : freqs.map { |f| "#{f.round(0)}MHz" }.join(', ')
274
- else
275
- cores = begin
276
- `sysctl -n hw.ncpu 2>/dev/null`.to_i
277
- rescue
278
- 0
279
- end
280
-
281
- raw_freq_hz = begin
282
- `sysctl -n hw.cpufrequency 2>/dev/null`.to_i
283
- rescue
284
- 0
285
- end
286
-
287
- freq_display =
288
- if raw_freq_hz > 0
289
- mhz = (raw_freq_hz.to_f / 1_000_000.0).round(0)
290
- "#{mhz.to_i}MHz"
291
- else
292
- "N/A"
293
- end
294
-
295
- usage = begin
296
- ps_output = `ps -A -o %cpu 2>/dev/null`
297
- lines = ps_output.lines
298
- values = lines[1..-1] || []
299
- sum = values.map { |l| l.to_f }.inject(0.0, :+)
300
- if cores > 0
301
- (sum / cores).round(1)
302
- else
303
- sum.round(1)
304
- end
305
- rescue
306
- 0.0
307
- end
308
- end
309
-
310
- "#{color("CPU Usage:",36)} #{color("#{usage}%",33)} | " \
311
- "#{color("Cores:",36)} #{color(cores.to_s,32)} | " \
312
- "#{color("Freqs:",36)} #{color(freq_display,35)}"
313
- end
314
-
315
- def ram_info
316
- case os_type
317
- when :linux
318
- if File.exist?('/proc/meminfo')
319
- meminfo = {}
320
- File.read('/proc/meminfo').each_line do |line|
321
- key, val = line.split(':')
322
- meminfo[key.strip] = val.strip.split.first.to_i * 1024 if key && val
323
- end
324
- total = meminfo['MemTotal'] || 0
325
- free = (meminfo['MemFree'] || 0) + (meminfo['Buffers'] || 0) + (meminfo['Cached'] || 0)
326
- used = total - free
327
- "#{color("RAM Usage:",36)} #{color(human_bytes(used),33)} / #{color(human_bytes(total),32)}"
328
- else
329
- "#{color("RAM Usage:",36)} Info not available"
330
- end
331
- else
332
- begin
333
- if os_type == :mac
334
- total = `sysctl -n hw.memsize 2>/dev/null`.to_i
335
- return "#{color("RAM Usage:",36)} Info not available" if total <= 0
336
-
337
- vm = `vm_stat 2>/dev/null`
338
- page_size = vm[/page size of (\d+) bytes/, 1].to_i
339
- page_size = 4096 if page_size <= 0
340
-
341
- stats = {}
342
- vm.each_line do |line|
343
- if line =~ /^(.+):\s+(\d+)\./
344
- stats[$1] = $2.to_i
345
- end
346
- end
347
-
348
- used_pages = 0
349
- %w[Pages active Pages wired down Pages occupied by compressor].each do |k|
350
- used_pages += stats[k].to_i
351
- end
352
- used = used_pages * page_size
353
-
354
- "#{color("RAM Usage:",36)} #{color(human_bytes(used),33)} / #{color(human_bytes(total),32)}"
355
- else
356
- total = `sysctl -n hw.physmem 2>/dev/null`.to_i
357
- total = `sysctl -n hw.realmem 2>/dev/null`.to_i if total <= 0
358
- return "#{color("RAM Usage:",36)} Info not available" if total <= 0
359
- "#{color("RAM Usage:",36)} #{color("Unknown",33)} / #{color(human_bytes(total),32)}"
360
- end
361
- rescue
362
- "#{color("RAM Usage:",36)} Info not available"
363
- end
364
- end
365
- end
366
-
367
- def storage_info
368
- begin
369
- require 'sys/filesystem'
370
- stat = Sys::Filesystem.stat(Dir.pwd)
371
- total = stat.bytes_total
372
- free = stat.bytes_available
373
- used = total - free
374
- "#{color("Storage Usage (#{Dir.pwd}):",36)} #{color(human_bytes(used),33)} / #{color(human_bytes(total),32)}"
375
- rescue LoadError
376
- "#{color("Install 'sys-filesystem' gem for storage info:",31)} #{color('gem install sys-filesystem',33)}"
377
- rescue
378
- "#{color("Storage Usage:",36)} Info not available"
379
- end
380
- end
381
-
382
- # ---------------- Builtin helpers ----------------
383
- def builtin_help
384
- puts color('=' * 60, "1;35")
385
- puts color("srsh #{SRSH_VERSION} - Builtin Commands", "1;33")
386
- puts color(sprintf("%-15s%-45s", "Command", "Description"), "1;36")
387
- puts color('-' * 60, "1;34")
388
- puts color(sprintf("%-15s", "cd"), "1;36") + "Change directory"
389
- puts color(sprintf("%-15s", "pwd"), "1;36") + "Print working directory"
390
- puts color(sprintf("%-15s", "exit / quit"), "1;36") + "Exit the shell"
391
- puts color(sprintf("%-15s", "alias"), "1;36") + "Create or list aliases"
392
- puts color(sprintf("%-15s", "unalias"), "1;36") + "Remove alias"
393
- puts color(sprintf("%-15s", "jobs"), "1;36") + "Show background jobs (tracked pids)"
394
- puts color(sprintf("%-15s", "systemfetch"), "1;36") + "Display system information"
395
- puts color(sprintf("%-15s", "hist"), "1;36") + "Show shell history"
396
- puts color(sprintf("%-15s", "clearhist"), "1;36") + "Clear saved history (memory + file)"
397
- puts color(sprintf("%-15s", "put"), "1;36") + "Print text (like echo)"
398
- puts color(sprintf("%-15s", "help"), "1;36") + "Show this help message"
399
- puts color('=' * 60, "1;35")
400
- end
401
-
402
- def builtin_systemfetch
403
- user = ENV['USER'] || Etc.getlogin || Etc.getpwuid.name rescue ENV['USER'] || Etc.getlogin
404
- host = Socket.gethostname
405
- os = detect_distro
406
- ruby_ver = RUBY_VERSION
407
-
408
- cpu_percent = begin
409
- case os_type
410
- when :linux
411
- prev = read_cpu_times
412
- sleep 0.05
413
- cur = read_cpu_times
414
- calculate_cpu_usage(prev, cur).round(1)
415
- else
416
- cores = `sysctl -n hw.ncpu 2>/dev/null`.to_i rescue 0
417
- ps_output = `ps -A -o %cpu 2>/dev/null`
418
- lines = ps_output.lines
419
- values = lines[1..-1] || []
420
- sum = values.map { |l| l.to_f }.inject(0.0, :+)
421
- if cores > 0
422
- (sum / cores).round(1)
423
- else
424
- sum.round(1)
425
- end
426
- end
427
- rescue
428
- 0.0
429
- end
430
-
431
- mem_percent = begin
432
- case os_type
433
- when :linux
434
- if File.exist?('/proc/meminfo')
435
- meminfo = {}
436
- File.read('/proc/meminfo').each_line do |line|
437
- k, v = line.split(':')
438
- meminfo[k.strip] = v.strip.split.first.to_i * 1024 if k && v
439
- end
440
- total = meminfo['MemTotal'] || 1
441
- free = (meminfo['MemAvailable'] || meminfo['MemFree'] || 0)
442
- used = total - free
443
- (used.to_f / total.to_f * 100).round(1)
444
- else
445
- 0.0
446
- end
447
- when :mac
448
- total = `sysctl -n hw.memsize 2>/dev/null`.to_i
449
- if total <= 0
450
- 0.0
451
- else
452
- vm = `vm_stat 2>/dev/null`
453
- page_size = vm[/page size of (\d+) bytes/, 1].to_i
454
- page_size = 4096 if page_size <= 0
455
-
456
- stats = {}
457
- vm.each_line do |line|
458
- if line =~ /^(.+):\s+(\d+)\./
459
- stats[$1] = $2.to_i
460
- end
461
- end
462
-
463
- used_pages = 0
464
- %w[Pages active Pages wired down Pages occupied by compressor].each do |k|
465
- used_pages += stats[k].to_i
466
- end
467
- used = used_pages * page_size
468
- ((used.to_f / total.to_f) * 100).round(1)
469
- end
470
- else
471
- 0.0
472
- end
473
- rescue
474
- 0.0
475
- end
476
-
477
- puts color('=' * 60, "1;35")
478
- puts color("srsh System Information", "1;33")
479
- puts color("User: ", "1;36") + color("#{user}@#{host}", "0;37")
480
- puts color("OS: ", "1;36") + color(os, "0;37")
481
- puts color("Shell: ", "1;36") + color("srsh v#{SRSH_VERSION}", "0;37")
482
- puts color("Ruby: ", "1;36") + color(ruby_ver, "0;37")
483
- puts color("CPU Usage: ", "1;36") + nice_bar(cpu_percent / 100.0, 30, 32)
484
- puts color("RAM Usage: ", "1;36") + nice_bar(mem_percent / 100.0, 30, 35)
485
- puts color('=' * 60, "1;35")
486
- end
487
-
488
- def builtin_jobs
489
- if $child_pids.empty?
490
- puts color("No tracked child jobs.", 36)
491
- return
492
- end
493
- $child_pids.each do |pid|
494
- status = begin
495
- Process.kill(0, pid)
496
- 'running'
497
- rescue Errno::ESRCH
498
- 'done'
499
- rescue Errno::EPERM
500
- 'running'
501
- end
502
- puts "[#{pid}] #{status}"
503
- end
504
- end
505
-
506
- def builtin_hist
507
- HISTORY.each_with_index do |h, i|
508
- printf "%5d %s\n", i + 1, h
509
- end
510
- end
511
-
512
- def builtin_clearhist
513
- HISTORY.clear
514
- if File.exist?(HISTORY_FILE)
515
- begin
516
- File.delete(HISTORY_FILE)
517
- rescue
518
- end
519
- end
520
- puts color("History cleared (memory + file).", 32)
521
- end
522
-
523
- # -------- Pretty column printer for colored text (used by ls) --------
524
- def print_columns_colored(labels)
525
- return if labels.nil? || labels.empty?
526
-
527
- width = terminal_width
528
- visible_lengths = labels.map { |s| strip_ansi(s).length }
529
- max_len = visible_lengths.max || 0
530
- col_width = [max_len + 2, 4].max
531
- cols = [width / col_width, 1].max
532
- rows = (labels.length.to_f / cols).ceil
533
-
534
- rows.times do |r|
535
- line = ""
536
- cols.times do |c|
537
- idx = c * rows + r
538
- break if idx >= labels.length
539
- label = labels[idx]
540
- visible = strip_ansi(label).length
541
- padding = col_width - visible
542
- line << label << (" " * padding)
543
- end
544
- STDOUT.print("\r")
545
- STDOUT.print(line.rstrip)
546
- STDOUT.print("\n")
547
- end
548
- end
549
-
550
- def builtin_ls(path = ".")
551
- begin
552
- entries = Dir.children(path).sort
553
- rescue => e
554
- puts color("ls: #{e.message}", 31)
555
- return
556
- end
557
-
558
- labels = entries.map do |name|
559
- full = File.join(path, name)
560
- begin
561
- if File.directory?(full)
562
- color("#{name}/", 36)
563
- elsif File.executable?(full)
564
- color("#{name}*", 32)
565
- else
566
- color(name, 37)
567
- end
568
- rescue
569
- name
570
- end
571
- end
572
-
573
- print_columns_colored(labels)
574
- end
575
-
576
- # ---------------- rsh scripting helpers ----------------
577
-
578
- # Evaluate rsh condition expressions, Ruby-style with $VARS
579
- def eval_rsh_expr(expr)
580
- return false if expr.nil? || expr.strip.empty?
581
- s = expr.to_s
582
-
583
- s = s.gsub(/\$([A-Za-z_][A-Za-z0-9_]*)/) do
584
- (ENV[$1] || "").inspect
585
- end
586
-
587
- s = s.gsub(/\$(\d+)/) do
588
- idx = $1.to_i
589
- val = ($rsh_positional && $rsh_positional[idx]) || ""
590
- val.inspect
591
- end
592
-
593
- begin
594
- !!eval(s)
595
- rescue
596
- false
597
- end
598
- end
599
-
600
- def rsh_find_if_bounds(lines, start_idx)
601
- depth = 1
602
- else_idx = nil
603
- i = start_idx + 1
604
- while i < lines.length
605
- line = lines[i].to_s.strip
606
- if line.start_with?("if ")
607
- depth += 1
608
- elsif line.start_with?("while ")
609
- depth += 1
610
- elsif line.start_with?("fn ")
611
- depth += 1
612
- elsif line == "end"
613
- depth -= 1
614
- return [else_idx, i] if depth == 0
615
- elsif line == "else" && depth == 1
616
- else_idx = i
617
- end
618
- i += 1
619
- end
620
- raise "Unmatched 'if' in rsh script"
621
- end
622
-
623
- def rsh_find_block_end(lines, start_idx)
624
- depth = 1
625
- i = start_idx + 1
626
- while i < lines.length
627
- line = lines[i].to_s.strip
628
- if line.start_with?("if ") || line.start_with?("while ") || line.start_with?("fn ")
629
- depth += 1
630
- elsif line == "end"
631
- depth -= 1
632
- return i if depth == 0
633
- end
634
- i += 1
635
- end
636
- raise "Unmatched block in rsh script"
637
- end
638
-
639
- def run_rsh_block(lines, start_idx, end_idx)
640
- i = start_idx
641
- while i < end_idx
642
- raw = lines[i]
643
- i += 1
644
- next if raw.nil?
645
- line = raw.strip
646
- next if line.empty? || line.start_with?("#")
647
-
648
- if line.start_with?("if ")
649
- cond_expr = line[3..-1].strip
650
- else_idx, end_idx_2 = rsh_find_if_bounds(lines, i - 1)
651
- if eval_rsh_expr(cond_expr)
652
- body_end = else_idx || end_idx_2
653
- run_rsh_block(lines, i, body_end)
654
- elsif else_idx
655
- run_rsh_block(lines, else_idx + 1, end_idx_2)
656
- end
657
- i = end_idx_2 + 1
658
- next
659
- elsif line.start_with?("while ")
660
- cond_expr = line[6..-1].strip
661
- block_end = rsh_find_block_end(lines, i - 1)
662
- while eval_rsh_expr(cond_expr)
663
- run_rsh_block(lines, i, block_end)
664
- end
665
- i = block_end + 1
666
- next
667
- elsif line.start_with?("fn ")
668
- parts = line.split
669
- name = parts[1]
670
- argnames = parts[2..-1] || []
671
- block_end = rsh_find_block_end(lines, i - 1)
672
- $rsh_functions[name] = {
673
- args: argnames,
674
- body: lines[i...block_end]
675
- }
676
- i = block_end + 1
677
- next
678
- else
679
- run_input_line(line)
680
- end
681
- end
682
- end
683
-
684
- def rsh_run_script(script_path, argv)
685
- $rsh_script_mode = true
686
- $rsh_positional = {}
687
- $rsh_positional[0] = File.basename(script_path)
688
- argv.each_with_index do |val, idx|
689
- $rsh_positional[idx + 1] = val
690
- end
691
-
692
- lines = File.readlines(script_path, chomp: true)
693
- if lines[0] && lines[0].start_with?("#!")
694
- lines = lines[1..-1] || []
695
- end
696
- run_rsh_block(lines, 0, lines.length)
697
- end
698
-
699
- def rsh_call_function(name, argv)
700
- fn = $rsh_functions[name]
701
- return unless fn
702
-
703
- saved_positional = $rsh_positional
704
- $rsh_positional = {}
705
- $rsh_positional[0] = name
706
- fn[:args].each_with_index do |argname, idx|
707
- val = argv[idx] || ""
708
- ENV[argname] = val
709
- $rsh_positional[idx + 1] = val
710
- end
711
-
712
- run_rsh_block(fn[:body], 0, fn[:body].length)
713
- ensure
714
- $rsh_positional = saved_positional
715
- end
716
-
717
- # ---------------- External Execution Helper ----------------
718
- def exec_external(args, stdin_file, stdout_file, append)
719
- command_path = args[0]
720
- if command_path && (command_path.include?('/') || command_path.start_with?('.'))
721
- begin
722
- if File.directory?(command_path)
723
- puts color("srsh: #{command_path}: is a directory", 31)
724
- return
725
- end
726
- rescue
727
- end
728
- end
729
-
730
- pid = fork do
731
- Signal.trap("INT","DEFAULT")
732
- if stdin_file
733
- begin
734
- STDIN.reopen(File.open(stdin_file,'r'))
735
- rescue
736
- end
737
- end
738
- if stdout_file
739
- begin
740
- STDOUT.reopen(File.open(stdout_file, append ? 'a' : 'w'))
741
- rescue
742
- end
743
- end
744
- begin
745
- exec(*args)
746
- rescue Errno::ENOENT
747
- puts color("Command not found: #{args[0]}", rainbow_codes.sample)
748
- exit 127
749
- rescue Errno::EACCES
750
- puts color("Permission denied: #{args[0]}", 31)
751
- exit 126
752
- end
753
- end
754
-
755
- $child_pids << pid
756
- begin
757
- Process.wait(pid)
758
- $last_status = $?.exitstatus || 0
759
- rescue Interrupt
760
- ensure
761
- $child_pids.delete(pid)
762
- end
763
- end
764
-
765
- # ---------------- Command Execution ----------------
766
- def run_command(cmd)
767
- cmd = cmd.to_s
768
- cmd = expand_aliases(cmd.strip)
769
- cmd = expand_vars(cmd.strip)
770
- cmd, stdin_file, stdout_file, append = parse_redirection(cmd)
771
- args = Shellwords.shellsplit(cmd) rescue []
772
- return if args.empty?
773
-
774
- # rsh functions
775
- if $rsh_functions.key?(args[0])
776
- rsh_call_function(args[0], args[1..-1] || [])
777
- return
778
- end
779
-
780
- case args[0]
781
- when 'ls'
782
- if args.length == 1
783
- builtin_ls(".")
784
- return
785
- elsif args.length == 2 && !args[1].start_with?("-")
786
- builtin_ls(args[1])
787
- return
788
- end
789
- exec_external(args, stdin_file, stdout_file, append)
790
- return
791
- when 'cd'
792
- path = args[1] ? File.expand_path(args[1]) : ENV['HOME']
793
- if !File.exist?(path)
794
- puts color("cd: no such file or directory: #{args[1]}", 31)
795
- elsif !File.directory?(path)
796
- puts color("cd: not a directory: #{args[1]}", 31)
797
- else
798
- Dir.chdir(path)
799
- end
800
- return
801
- when 'exit','quit'
802
- $child_pids.each { |pid| Process.kill("TERM", pid) rescue nil }
803
- exit 0
804
- when 'alias'
805
- if args[1].nil?
806
- $aliases.each { |k,v| puts "#{k}='#{v}'" }
807
- else
808
- arg = args[1..].join(' ')
809
- if arg =~ /^(\w+)=([\"']?)(.+?)\2$/
810
- $aliases[$1] = $3
811
- else
812
- puts color("Invalid alias format", 31)
813
- end
814
- end
815
- return
816
- when 'unalias'
817
- if args[1]
818
- $aliases.delete(args[1])
819
- else
820
- puts color("unalias: usage: unalias name", 31)
821
- end
822
- return
823
- when 'help'
824
- builtin_help
825
- return
826
- when 'systemfetch'
827
- builtin_systemfetch
828
- return
829
- when 'jobs'
830
- builtin_jobs
831
- return
832
- when 'pwd'
833
- puts color(Dir.pwd, 36)
834
- return
835
- when 'hist'
836
- builtin_hist
837
- return
838
- when 'clearhist'
839
- builtin_clearhist
840
- return
841
- when 'put'
842
- msg = args[1..-1].join(' ')
843
- puts msg
844
- return
845
- end
846
-
847
- exec_external(args, stdin_file, stdout_file, append)
848
- end
849
-
850
- # ---------------- Chained Commands ----------------
851
- def run_input_line(input)
852
- commands = input.split(/&&|;/).map(&:strip)
853
- commands.each do |cmd|
854
- next if cmd.empty?
855
- run_command(cmd)
856
- end
857
- end
858
-
859
- # ---------------- Prompt ----------------
860
- hostname = Socket.gethostname
861
- prompt_color = random_color
862
-
863
- def prompt(hostname, prompt_color)
864
- "#{color(Dir.pwd,33)} #{color(hostname,36)}#{color(' > ', prompt_color)}"
865
- end
866
-
867
- # ---------------- Ghost + Completion Helpers ----------------
868
- def history_ghost_for(line)
869
- return nil if line.nil? || line.empty?
870
- HISTORY.reverse_each do |h|
871
- next if h.nil? || h.empty?
872
- next if h.start_with?("[completions:")
873
- next unless h.start_with?(line)
874
- next if h == line
875
- return h
876
- end
877
- nil
878
- end
879
-
880
- def tab_completions_for(prefix, first_word, at_first_word)
881
- prefix ||= ""
882
-
883
- dir = "."
884
- base = prefix
885
-
886
- if prefix.include?('/')
887
- if prefix.end_with?('/')
888
- dir = prefix.chomp('/')
889
- base = ""
890
- else
891
- dir = File.dirname(prefix)
892
- base = File.basename(prefix)
893
- end
894
- dir = "." if dir.nil? || dir.empty?
895
- end
896
-
897
- file_completions = []
898
- if Dir.exist?(dir)
899
- Dir.children(dir).each do |entry|
900
- next unless entry.start_with?(base)
901
- full = File.join(dir, entry)
902
-
903
- rel =
904
- if dir == "."
905
- entry
906
- else
907
- File.join(File.dirname(prefix), entry)
908
- end
909
-
910
- case first_word
911
- when "cd"
912
- next unless File.directory?(full)
913
- rel = rel + "/" unless rel.end_with?("/")
914
- file_completions << rel
915
- when "cat"
916
- next unless File.file?(full)
917
- file_completions << rel
918
- else
919
- rel = rel + "/" if File.directory?(full) && !rel.end_with?("/")
920
- file_completions << rel
921
- end
922
- end
923
- end
924
-
925
- exec_completions = []
926
- if first_word != "cat" && first_word != "cd" && at_first_word && !prefix.include?('/')
927
- path_entries = (ENV['PATH'] || "").split(':')
928
- execs = path_entries.flat_map do |p|
929
- Dir.glob("#{p}/*").map { |f|
930
- File.basename(f) if File.executable?(f) && !File.directory?(f)
931
- }.compact rescue []
932
- end
933
- exec_completions = execs.grep(/^#{Regexp.escape(prefix)}/)
934
- end
935
-
936
- (file_completions + exec_completions).uniq
937
- end
938
-
939
- def longest_common_prefix(strings)
940
- return "" if strings.empty?
941
- shortest = strings.min_by(&:length)
942
- shortest.length.times do |i|
943
- c = shortest[i]
944
- strings.each do |s|
945
- return shortest[0...i] if s[i] != c
946
- end
947
- end
948
- shortest
949
- end
950
-
951
- def render_line(prompt_str, buffer, cursor, show_ghost = true)
952
- buffer = buffer || ""
953
- cursor = [[cursor, 0].max, buffer.length].min
954
-
955
- ghost_tail = ""
956
- if show_ghost && cursor == buffer.length
957
- suggestion = history_ghost_for(buffer)
958
- ghost_tail = suggestion ? suggestion[buffer.length..-1].to_s : ""
959
- end
960
-
961
- width = terminal_width
962
- prompt_vis = strip_ansi(prompt_str).length
963
- total_vis = prompt_vis + buffer.length + ghost_tail.length
964
- rows = [(total_vis.to_f / width).ceil, 1].max
965
-
966
- # Clear previous render block (only what we drew last time)
967
- if $last_render_rows && $last_render_rows > 0
968
- STDOUT.print("\r")
969
- ($last_render_rows - 1).times do
970
- STDOUT.print("\e[1A\r") # move up a line, to column 0
971
- end
972
- $last_render_rows.times do |i|
973
- STDOUT.print("\e[0K") # clear this line
974
- STDOUT.print("\n") if i < $last_render_rows - 1
975
- end
976
- ($last_render_rows - 1).times do
977
- STDOUT.print("\e[1A\r") # move back up to first line of block
978
- end
979
- end
980
-
981
- STDOUT.print("\r")
982
- STDOUT.print(prompt_str)
983
- STDOUT.print(buffer)
984
- STDOUT.print(color(ghost_tail, "2")) unless ghost_tail.empty?
985
-
986
- move_left = ghost_tail.length + (buffer.length - cursor)
987
- STDOUT.print("\e[#{move_left}D") if move_left > 0
988
- STDOUT.flush
989
-
990
- $last_render_rows = rows
991
- end
992
-
993
- # --------- NEAT MULTI-COLUMN TAB LIST (bash-style) ----------
994
- def print_tab_list(comps)
995
- return if comps.empty?
996
-
997
- width = terminal_width
998
- max_len = comps.map { |s| s.length }.max || 0
999
- col_width = [max_len + 2, 4].max
1000
- cols = [width / col_width, 1].max
1001
- rows = (comps.length.to_f / cols).ceil
1002
-
1003
- STDOUT.print("\r\n")
1004
- rows.times do |r|
1005
- line = ""
1006
- cols.times do |c|
1007
- idx = c * rows + r
1008
- break if idx >= comps.length
1009
- item = comps[idx]
1010
- padding = col_width - item.length
1011
- line << item << (" " * padding)
1012
- end
1013
- STDOUT.print("\r")
1014
- STDOUT.print(line.rstrip)
1015
- STDOUT.print("\n")
1016
- end
1017
- STDOUT.print("\r\n")
1018
- STDOUT.flush
1019
- end
1020
-
1021
- def handle_tab_completion(prompt_str, buffer, cursor, last_tab_prefix, tab_cycle)
1022
- buffer = buffer || ""
1023
- cursor = [[cursor, 0].max, buffer.length].min
1024
-
1025
- wstart = buffer.rindex(/[ \t]/, cursor - 1) || -1
1026
- wstart += 1
1027
- prefix = buffer[wstart...cursor] || ""
1028
-
1029
- before_word = buffer[0...wstart]
1030
- at_first_word = before_word.strip.empty?
1031
- first_word = buffer.strip.split(/\s+/, 2)[0] || ""
1032
-
1033
- comps = tab_completions_for(prefix, first_word, at_first_word)
1034
- return [buffer, cursor, nil, 0, false] if comps.empty?
1035
-
1036
- if comps.size == 1
1037
- new_word = comps.first
1038
- buffer = buffer[0...wstart] + new_word + buffer[cursor..-1].to_s
1039
- cursor = wstart + new_word.length
1040
- return [buffer, cursor, nil, 0, true]
1041
- end
1042
-
1043
- if prefix != last_tab_prefix
1044
- lcp = longest_common_prefix(comps)
1045
- if lcp && lcp.length > prefix.length
1046
- buffer = buffer[0...wstart] + lcp + buffer[cursor..-1].to_s
1047
- cursor = wstart + lcp.length
1048
- else
1049
- STDOUT.print("\a")
1050
- end
1051
- last_tab_prefix = prefix
1052
- tab_cycle = 1
1053
- return [buffer, cursor, last_tab_prefix, tab_cycle, false]
1054
- else
1055
- # Second tab on same prefix: show list
1056
- render_line(prompt_str, buffer, cursor, false)
1057
- print_tab_list(comps)
1058
- last_tab_prefix = prefix
1059
- tab_cycle += 1
1060
- return [buffer, cursor, last_tab_prefix, tab_cycle, true]
1061
- end
1062
- end
1063
-
1064
- def read_line_with_ghost(prompt_str)
1065
- buffer = ""
1066
- cursor = 0
1067
- hist_index = HISTORY.length
1068
- saved_line_for_history = ""
1069
- last_tab_prefix = nil
1070
- tab_cycle = 0
1071
-
1072
- render_line(prompt_str, buffer, cursor)
1073
-
1074
- status = :ok
1075
-
1076
- IO.console.raw do |io|
1077
- loop do
1078
- ch = io.getch
1079
-
1080
- case ch
1081
- when "\r", "\n"
1082
- cursor = buffer.length
1083
- render_line(prompt_str, buffer, cursor, false)
1084
- STDOUT.print("\r\n")
1085
- STDOUT.flush
1086
- break
1087
- when "\u0003" # Ctrl-C
1088
- STDOUT.print("^C\r\n")
1089
- STDOUT.flush
1090
- status = :interrupt
1091
- buffer = ""
1092
- break
1093
- when "\u0004" # Ctrl-D
1094
- if buffer.empty?
1095
- status = :eof
1096
- buffer = nil
1097
- STDOUT.print("\r\n")
1098
- STDOUT.flush
1099
- break
1100
- else
1101
- # ignore when line not empty
1102
- end
1103
- when "\u0001" # Ctrl-A - move to beginning of line
1104
- cursor = 0
1105
- last_tab_prefix = nil
1106
- tab_cycle = 0
1107
- when "\u007F", "\b" # Backspace
1108
- if cursor > 0
1109
- buffer.slice!(cursor - 1)
1110
- cursor -= 1
1111
- end
1112
- last_tab_prefix = nil
1113
- tab_cycle = 0
1114
- when "\t" # Tab completion
1115
- buffer, cursor, last_tab_prefix, tab_cycle, printed =
1116
- handle_tab_completion(prompt_str, buffer, cursor, last_tab_prefix, tab_cycle)
1117
- # After showing completion list, reset render rows so the next prompt
1118
- # redraw only clears the current input line, not the completion block.
1119
- $last_render_rows = 1 if printed
1120
- when "\e" # Escape sequences (arrows, home/end)
1121
- seq1 = io.getch
1122
- seq2 = io.getch
1123
- if seq1 == "[" && seq2
1124
- case seq2
1125
- when "A" # Up
1126
- if hist_index == HISTORY.length
1127
- saved_line_for_history = buffer.dup
1128
- end
1129
- if hist_index > 0
1130
- hist_index -= 1
1131
- buffer = HISTORY[hist_index] || ""
1132
- cursor = buffer.length
1133
- end
1134
- when "B" # Down
1135
- if hist_index < HISTORY.length - 1
1136
- hist_index += 1
1137
- buffer = HISTORY[hist_index] || ""
1138
- cursor = buffer.length
1139
- elsif hist_index == HISTORY.length - 1
1140
- hist_index = HISTORY.length
1141
- buffer = saved_line_for_history || ""
1142
- cursor = buffer.length
1143
- end
1144
- when "C" # Right
1145
- if cursor < buffer.length
1146
- cursor += 1
1147
- else
1148
- suggestion = history_ghost_for(buffer)
1149
- if suggestion
1150
- buffer = suggestion
1151
- cursor = buffer.length
1152
- end
1153
- end
1154
- when "D" # Left
1155
- cursor -= 1 if cursor > 0
1156
- when "H" # Home
1157
- cursor = 0
1158
- when "F" # End
1159
- cursor = buffer.length
1160
- end
1161
- end
1162
- last_tab_prefix = nil
1163
- tab_cycle = 0
1164
- else
1165
- if ch.ord >= 32 && ch.ord != 127
1166
- buffer.insert(cursor, ch)
1167
- cursor += 1
1168
- hist_index = HISTORY.length
1169
- last_tab_prefix = nil
1170
- tab_cycle = 0
1171
- end
1172
- end
1173
-
1174
- render_line(prompt_str, buffer, cursor) if status == :ok
1175
- end
1176
- end
1177
-
1178
- [status, buffer]
1179
- end
1180
-
1181
- # ---------------- Welcome ----------------
1182
- def print_welcome
1183
- puts color("Welcome to srsh #{SRSH_VERSION} - your simple Ruby shell!",36)
1184
- puts color("Current Time:",36) + " " + color(current_time,34)
1185
- puts cpu_info
1186
- puts ram_info
1187
- puts storage_info
1188
- puts dynamic_quote
1189
- puts
1190
- puts color("Coded with love by https://github.com/RobertFlexx",90)
1191
- puts
1192
- end
1193
-
1194
- # ---------------- Script vs interactive entry ----------------
1195
- if ARGV[0]
1196
- script_path = ARGV.shift
1197
- begin
1198
- rsh_run_script(script_path, ARGV)
1199
- rescue => e
1200
- STDERR.puts "rsh script error: #{e.class}: #{e.message}"
1201
- end
1202
- exit 0
1203
- end
1204
-
1205
- print_welcome
1206
-
1207
- # ---------------- Main Loop ----------------
1208
- loop do
1209
- print "\033]0;srsh-#{SRSH_VERSION}\007"
1210
- prompt_str = prompt(hostname, prompt_color)
1211
-
1212
- status, input = read_line_with_ghost(prompt_str)
1213
-
1214
- break if status == :eof
1215
- next if status == :interrupt
1216
-
1217
- next if input.nil?
1218
- input = input.strip
1219
- next if input.empty?
1220
-
1221
- HISTORY << input
1222
-
1223
- run_input_line(input)
1224
- end