srsh 0.7.1 → 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.
@@ -0,0 +1,2416 @@
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
+ require 'fileutils'
9
+ require 'json'
10
+
11
+ # ---------------- Version ----------------
12
+ SRSH_VERSION = "0.8.0"
13
+
14
+ $0 = "srsh-#{SRSH_VERSION}"
15
+ ENV['SHELL'] = "srsh-#{SRSH_VERSION}"
16
+ print "\033]0;srsh-#{SRSH_VERSION}\007"
17
+
18
+ Dir.chdir(ENV['HOME']) if ENV['HOME']
19
+
20
+ # ---------------- Paths ----------------
21
+ SRSH_DIR = File.join(Dir.home, ".srsh")
22
+ SRSH_PLUGINS_DIR= File.join(SRSH_DIR, "plugins")
23
+ SRSH_THEMES_DIR = File.join(SRSH_DIR, "themes")
24
+ SRSH_CONFIG = File.join(SRSH_DIR, "config")
25
+ HISTORY_FILE = File.join(Dir.home, ".srsh_history")
26
+ RC_FILE = File.join(Dir.home, ".srshrc")
27
+ THEME_STATE_FILE= File.join(SRSH_DIR, "theme")
28
+
29
+ begin
30
+ FileUtils.mkdir_p(SRSH_PLUGINS_DIR)
31
+ FileUtils.mkdir_p(SRSH_THEMES_DIR)
32
+ rescue
33
+ end
34
+
35
+ # ---------------- Globals ----------------
36
+ $child_pids = []
37
+ $aliases = {}
38
+ $last_render_rows = 0
39
+ $last_status = 0
40
+
41
+ $rsh_functions = {} # name => { args:[...], body:[nodes] }
42
+ $rsh_positional = {} # 0..n => string
43
+ $rsh_call_depth = 0
44
+
45
+ $builtins = {} # name => proc(args)
46
+ $hooks = Hash.new { |h,k| h[k] = [] } # :pre_cmd, :post_cmd, :prompt
47
+
48
+ Signal.trap("INT", "IGNORE")
49
+
50
+ # Control-flow exceptions for the scripting engine
51
+ class RshBreak < StandardError; end
52
+ class RshContinue < StandardError; end
53
+ class RshReturn < StandardError; end
54
+
55
+ # ---------------- Config helpers ----------------
56
+
57
+ def read_kv_file(path)
58
+ h = {}
59
+ return h unless File.exist?(path)
60
+ File.foreach(path) do |line|
61
+ line = line.to_s.strip
62
+ next if line.empty? || line.start_with?("#")
63
+ k, v = line.split("=", 2)
64
+ next if k.nil? || v.nil?
65
+ h[k.strip] = v.strip
66
+ end
67
+ h
68
+ rescue
69
+ {}
70
+ end
71
+
72
+ def write_kv_file(path, h)
73
+ dir = File.dirname(path)
74
+ FileUtils.mkdir_p(dir) rescue nil
75
+ tmp = path + ".tmp"
76
+ File.open(tmp, "w") do |f|
77
+ h.keys.sort.each { |k| f.puts("#{k}=#{h[k]}") }
78
+ end
79
+ File.rename(tmp, path)
80
+ rescue
81
+ nil
82
+ end
83
+
84
+ # ---------------- Theme system ----------------
85
+
86
+ def supports_truecolor?
87
+ ct = (ENV['COLORTERM'] || "").downcase
88
+ return true if ct.include?("truecolor") || ct.include?("24bit")
89
+ false
90
+ end
91
+
92
+ def term_colors
93
+ @term_colors ||= begin
94
+ out = `tput colors 2>/dev/null`.to_i
95
+ out > 0 ? out : 8
96
+ rescue
97
+ 8
98
+ end
99
+ end
100
+
101
+ def fg_rgb(r,g,b)
102
+ "38;2;#{r};#{g};#{b}"
103
+ end
104
+
105
+ def fg_256(n)
106
+ "38;5;#{n}"
107
+ end
108
+
109
+ DEFAULT_THEMES = begin
110
+ ghost = if supports_truecolor?
111
+ fg_rgb(140,140,140)
112
+ elsif term_colors >= 256
113
+ fg_256(244)
114
+ else
115
+ "90" # bright black / gray
116
+ end
117
+
118
+ {
119
+ "classic" => {
120
+ name: "classic",
121
+ ui_border: "1;35",
122
+ ui_title: "1;33",
123
+ ui_hdr: "1;36",
124
+ ui_key: "1;36",
125
+ ui_val: "0;37",
126
+ ok: "32",
127
+ warn: "33",
128
+ err: "31",
129
+ dim: ghost,
130
+ prompt_path: "33",
131
+ prompt_host: "36",
132
+ prompt_mark: "35",
133
+ quote_rainbow: true,
134
+ },
135
+
136
+ "mono" => {
137
+ name: "mono",
138
+ ui_border: "1;37",
139
+ ui_title: "1;37",
140
+ ui_hdr: "0;37",
141
+ ui_key: "0;37",
142
+ ui_val: "0;37",
143
+ ok: "0;37",
144
+ warn: "0;37",
145
+ err: "0;37",
146
+ dim: ghost,
147
+ prompt_path: "0;37",
148
+ prompt_host: "0;37",
149
+ prompt_mark: "0;37",
150
+ quote_rainbow: false,
151
+ },
152
+
153
+ "neon" => {
154
+ name: "neon",
155
+ ui_border: "1;35",
156
+ ui_title: "1;92",
157
+ ui_hdr: "1;96",
158
+ ui_key: "1;95",
159
+ ui_val: "0;37",
160
+ ok: "1;92",
161
+ warn: "1;93",
162
+ err: "1;91",
163
+ dim: ghost,
164
+ prompt_path: "1;93",
165
+ prompt_host: "1;96",
166
+ prompt_mark: "1;95",
167
+ quote_rainbow: true,
168
+ },
169
+
170
+ # special request: light blue + deep ocean blue vibe
171
+ "ocean" => begin
172
+ deep = supports_truecolor? ? fg_rgb(0, 86, 180) : (term_colors >= 256 ? fg_256(25) : "34")
173
+ light = supports_truecolor? ? fg_rgb(120, 210, 255) : (term_colors >= 256 ? fg_256(81) : "36")
174
+ {
175
+ name: "ocean",
176
+ ui_border: deep,
177
+ ui_title: light,
178
+ ui_hdr: light,
179
+ ui_key: deep,
180
+ ui_val: "0;37",
181
+ ok: light,
182
+ warn: "33",
183
+ err: "31",
184
+ dim: ghost,
185
+ prompt_path: deep,
186
+ prompt_host: light,
187
+ prompt_mark: light,
188
+ quote_rainbow: false,
189
+ }
190
+ end,
191
+ }
192
+ end
193
+
194
+ $themes = DEFAULT_THEMES.dup
195
+ $theme_name = nil
196
+ $theme = nil
197
+
198
+ def color(text, code)
199
+ text = text.to_s
200
+ return text if code.nil? || code.to_s.empty?
201
+ "\e[#{code}m#{text}\e[0m"
202
+ end
203
+
204
+ def t(key)
205
+ ($theme && $theme[key])
206
+ end
207
+
208
+ def ui(text, key)
209
+ color(text, t(key))
210
+ end
211
+
212
+ def load_user_themes!
213
+ # .theme format: key=value
214
+ Dir.glob(File.join(SRSH_THEMES_DIR, "*.theme")).each do |path|
215
+ name = File.basename(path, ".theme")
216
+ data = read_kv_file(path)
217
+ next if data.empty?
218
+ theme = { name: name.to_s }
219
+ data.each do |k, v|
220
+ theme[k.to_sym] = v
221
+ end
222
+ $themes[name] = theme
223
+ end
224
+
225
+ # .json format
226
+ Dir.glob(File.join(SRSH_THEMES_DIR, "*.json")).each do |path|
227
+ name = File.basename(path, ".json")
228
+ begin
229
+ obj = JSON.parse(File.read(path))
230
+ next unless obj.is_a?(Hash)
231
+ theme = { name: name.to_s }
232
+ obj.each { |k,v| theme[k.to_sym] = v.to_s }
233
+ $themes[name] = theme
234
+ rescue
235
+ end
236
+ end
237
+ rescue
238
+ end
239
+
240
+ def set_theme!(name)
241
+ name = name.to_s
242
+ th = $themes[name]
243
+ return false unless th.is_a?(Hash)
244
+ $theme_name = name
245
+ $theme = th
246
+ begin
247
+ File.write(THEME_STATE_FILE, name + "\n")
248
+ rescue
249
+ end
250
+ true
251
+ end
252
+
253
+ def load_theme_state!
254
+ load_user_themes!
255
+
256
+ # precedence: SRSH_THEME env, theme state file, config file
257
+ wanted = (ENV['SRSH_THEME'] || "").strip
258
+ if wanted.empty? && File.exist?(THEME_STATE_FILE)
259
+ wanted = File.read(THEME_STATE_FILE).to_s.strip
260
+ end
261
+ if wanted.empty?
262
+ cfg = read_kv_file(SRSH_CONFIG)
263
+ wanted = cfg['theme'].to_s.strip
264
+ end
265
+
266
+ wanted = "classic" if wanted.empty?
267
+ set_theme!(wanted) || set_theme!("classic")
268
+ end
269
+
270
+ load_theme_state!
271
+
272
+ # ---------------- History ----------------
273
+ HISTORY_MAX = begin
274
+ v = (ENV['SRSH_HISTORY_MAX'] || "5000").to_i
275
+ v = 5000 if v <= 0
276
+ v
277
+ end
278
+
279
+ HISTORY = if File.exist?(HISTORY_FILE)
280
+ File.readlines(HISTORY_FILE, chomp: true).first(HISTORY_MAX)
281
+ else
282
+ []
283
+ end
284
+
285
+ at_exit do
286
+ begin
287
+ trimmed = HISTORY.last(HISTORY_MAX)
288
+ File.open(HISTORY_FILE, "w") { |f| trimmed.each { |line| f.puts(line) } }
289
+ rescue
290
+ end
291
+ end
292
+
293
+ # ---------------- RC file (create if missing) ----------------
294
+ begin
295
+ unless File.exist?(RC_FILE)
296
+ File.write(RC_FILE, <<~RC)
297
+ # ~/.srshrc — srsh configuration (RSH)
298
+ # Created automatically by srsh v#{SRSH_VERSION}
299
+ #
300
+ # Examples:
301
+ # alias ll='ls'
302
+ # scheme ocean
303
+ # set EDITOR nano
304
+ #
305
+ # Plugins:
306
+ # drop .rsh files into ~/.srsh/plugins/ to auto-load
307
+ # Themes:
308
+ # drop .theme files into ~/.srsh/themes/ to add schemes
309
+ RC
310
+ end
311
+ rescue
312
+ end
313
+
314
+ # ---------------- Utilities ----------------
315
+
316
+ def rainbow_codes
317
+ [31, 33, 32, 36, 34, 35, 91, 93, 92, 96, 94, 95]
318
+ end
319
+
320
+ def human_bytes(bytes)
321
+ units = ['B', 'KB', 'MB', 'GB', 'TB']
322
+ size = bytes.to_f
323
+ unit = units.shift
324
+ while size > 1024 && !units.empty?
325
+ size /= 1024
326
+ unit = units.shift
327
+ end
328
+ "#{format('%.2f', size)} #{unit}"
329
+ end
330
+
331
+ def nice_bar(p, w = 30, code = nil)
332
+ p = [[p, 0.0].max, 1.0].min
333
+ f = (p * w).round
334
+ b = "█" * f + "░" * (w - f)
335
+ pct = (p * 100).to_i
336
+ bar = "[#{b}]"
337
+ code ||= t(:ok)
338
+ "#{color(bar, code)} #{color(sprintf("%3d%%", pct), t(:ui_val))}"
339
+ end
340
+
341
+ def terminal_width
342
+ IO.console.winsize[1]
343
+ rescue
344
+ 80
345
+ end
346
+
347
+ def strip_ansi(str)
348
+ str.to_s.gsub(/\e\[[0-9;]*m/, '')
349
+ end
350
+
351
+ # limit for command substitution output to avoid accidental memory nukes
352
+ CMD_SUBST_MAX = 256 * 1024
353
+
354
+ # Simple $(...) command substitution (no nesting)
355
+ def expand_command_substitutions(str)
356
+ return "" if str.nil?
357
+ s = str.to_s.dup
358
+
359
+ s.gsub(/\$\(([^()]*)\)/) do
360
+ inner = $1.to_s.strip
361
+ next "" if inner.empty?
362
+ begin
363
+ out = `#{inner} 2>/dev/null`
364
+ out = out.to_s
365
+ out = out.byteslice(0, CMD_SUBST_MAX) if out.bytesize > CMD_SUBST_MAX
366
+ out.strip
367
+ rescue
368
+ ""
369
+ end
370
+ end
371
+ end
372
+
373
+ # variable expansion: $VAR, $1, $2, $0, $? (and $(...))
374
+ def expand_vars(str)
375
+ return "" if str.nil?
376
+
377
+ s = expand_command_substitutions(str.to_s)
378
+
379
+ # $? -> last exit status
380
+ s = s.gsub(/\$\?/) { $last_status.to_s }
381
+
382
+ # $1, $2, $0 from positional table
383
+ s = s.gsub(/\$(\d+)/) do
384
+ idx = $1.to_i
385
+ ($rsh_positional && $rsh_positional[idx]) || ""
386
+ end
387
+
388
+ # $VARNAME from ENV
389
+ s.gsub(/\$([a-zA-Z_][a-zA-Z0-9_]*)/) { ENV[$1] || "" }
390
+ end
391
+
392
+ # ---------------- Redirections ----------------
393
+ # supports: <, >, >>, 2>, 2>> (simple, no quoting)
394
+ def parse_redirection(cmd)
395
+ stdin_file = nil
396
+ stdout_file = nil
397
+ stderr_file = nil
398
+ append_out = false
399
+ append_err = false
400
+
401
+ s = cmd.to_s.dup
402
+
403
+ # stderr redirection first
404
+ if s =~ /(.*)2>>\s*(\S+)\s*\z/
405
+ s = $1.strip
406
+ stderr_file= $2.strip
407
+ append_err = true
408
+ elsif s =~ /(.*)2>\s*(\S+)\s*\z/
409
+ s = $1.strip
410
+ stderr_file= $2.strip
411
+ end
412
+
413
+ if s =~ /(.*)>>\s*(\S+)\s*\z/
414
+ s = $1.strip
415
+ stdout_file= $2.strip
416
+ append_out = true
417
+ elsif s =~ /(.*)>\s*(\S+)\s*\z/
418
+ s = $1.strip
419
+ stdout_file= $2.strip
420
+ end
421
+
422
+ if s =~ /(.*)<\s*(\S+)\s*\z/
423
+ s = $1.strip
424
+ stdin_file= $2.strip
425
+ end
426
+
427
+ [s, stdin_file, stdout_file, append_out, stderr_file, append_err]
428
+ end
429
+
430
+ def with_redirections(stdin_file, stdout_file, append_out, stderr_file, append_err)
431
+ in_dup = STDIN.dup
432
+ out_dup = STDOUT.dup
433
+ err_dup = STDERR.dup
434
+
435
+ if stdin_file
436
+ STDIN.reopen(File.open(stdin_file, 'r')) rescue nil
437
+ end
438
+ if stdout_file
439
+ STDOUT.reopen(File.open(stdout_file, append_out ? 'a' : 'w')) rescue nil
440
+ end
441
+ if stderr_file
442
+ STDERR.reopen(File.open(stderr_file, append_err ? 'a' : 'w')) rescue nil
443
+ end
444
+
445
+ yield
446
+ ensure
447
+ STDIN.reopen(in_dup) rescue nil
448
+ STDOUT.reopen(out_dup) rescue nil
449
+ STDERR.reopen(err_dup) rescue nil
450
+ in_dup.close rescue nil
451
+ out_dup.close rescue nil
452
+ err_dup.close rescue nil
453
+ end
454
+
455
+ # ---------------- Aliases ----------------
456
+ def expand_aliases(cmd, seen = [])
457
+ return cmd if cmd.nil? || cmd.strip.empty?
458
+ first_word, rest = cmd.strip.split(' ', 2)
459
+ return cmd if seen.include?(first_word)
460
+ seen << first_word
461
+
462
+ if $aliases.key?(first_word)
463
+ replacement = $aliases[first_word]
464
+ expanded = expand_aliases(replacement, seen)
465
+ rest ? "#{expanded} #{rest}" : expanded
466
+ else
467
+ cmd
468
+ end
469
+ end
470
+
471
+ # ---------------- System Info ----------------
472
+ def current_time
473
+ Time.now.strftime("%Y-%m-%d %H:%M:%S %Z")
474
+ end
475
+
476
+ def detect_distro
477
+ if File.exist?('/etc/os-release')
478
+ line = File.read('/etc/os-release').lines.find { |l|
479
+ l.start_with?('PRETTY_NAME="') || l.start_with?('PRETTY_NAME=')
480
+ }
481
+ return line.split('=').last.strip.delete('"') if line
482
+ end
483
+ "#{RbConfig::CONFIG['host_os']}"
484
+ rescue
485
+ "#{RbConfig::CONFIG['host_os']}"
486
+ end
487
+
488
+ def os_type
489
+ host = RbConfig::CONFIG['host_os'].to_s
490
+ case host
491
+ when /linux/i then :linux
492
+ when /darwin/i then :mac
493
+ when /bsd/i then :bsd
494
+ else :other
495
+ end
496
+ end
497
+
498
+ # ---------------- Quotes ----------------
499
+ QUOTES = [
500
+ "Listen you flatpaker! - Terry Davis",
501
+ "Btw quotes have made a full rotation, some old ones may not exist (sorry)",
502
+ "Keep calm and ship it.",
503
+ "If it works, don't touch it. (Unless it's legacy, then definitely don't touch it.)",
504
+ "There’s no place like 127.0.0.1.",
505
+ "The computer is never wrong. The user is never right.",
506
+ "Unix is user-friendly. It's just picky about its friends.",
507
+ "A watched process never completes.",
508
+ "Pipes: the original microservices.",
509
+ "If you can read this, your terminal is working. Congrats.",
510
+ "Ctrl+C: the developer's parachute.",
511
+ "Nothing is permanent except the alias you forgot you set.",
512
+ "One does not simply exit vim.",
513
+ "If it compiles, it’s probably fine.",
514
+ "When in doubt, check $PATH.",
515
+ "I/O is lava.",
516
+ "Permissions are a feature, not a bug.",
517
+ "rm -rf is not a personality.",
518
+ "Your shell history knows too much.",
519
+ "If it’s slow, add caching. If it’s still slow, blame DNS.",
520
+ "Kernel panic: the OS's way of saying 'bruh'.",
521
+ "Logs don't lie. They just omit context.",
522
+ "Everything is a file. Including your mistakes.",
523
+ "Segfault: surprise!",
524
+ "If you can't fix it, make it a function.",
525
+ "The quickest optimization is deleting the feature.",
526
+ "Man pages: ancient scrolls of wisdom.",
527
+ "If at first you don’t succeed: read the error message.",
528
+ "The best tool is the one already installed.",
529
+ "A clean build is a suspicious build.",
530
+ "Git is not a backup, but it *tries*.",
531
+ "Sleep: the ultimate debugger.",
532
+ "Bash: because typing 'make it work' was too hard.",
533
+ "In POSIX we trust, in extensions we cope.",
534
+ "How is #{detect_distro}?",
535
+ "If it's on fire: commit, push, walk away.",
536
+ ]
537
+
538
+ $current_quote = QUOTES.sample
539
+
540
+ def dynamic_quote
541
+ return $current_quote unless t(:quote_rainbow)
542
+ chars = $current_quote.chars
543
+ rainbow = rainbow_codes.cycle
544
+ chars.map { |c| color(c, rainbow.next) }.join
545
+ end
546
+
547
+ # ---------------- CPU / RAM / Storage ----------------
548
+ def read_cpu_times
549
+ return [] unless File.exist?('/proc/stat')
550
+ cpu_line = File.readlines('/proc/stat').find { |line| line.start_with?('cpu ') }
551
+ return [] unless cpu_line
552
+ cpu_line.split[1..-1].map(&:to_i)
553
+ rescue
554
+ []
555
+ end
556
+
557
+ def calculate_cpu_usage(prev, current)
558
+ return 0.0 if prev.empty? || current.empty?
559
+ prev_idle = prev[3] + (prev[4] || 0)
560
+ idle = current[3] + (current[4] || 0)
561
+ prev_non_idle = prev[0] + prev[1] + prev[2] + (prev[5] || 0) + (prev[6] || 0) + (prev[7] || 0)
562
+ non_idle = current[0] + current[1] + current[2] + (current[5] || 0) + (current[6] || 0) + (current[7] || 0)
563
+ prev_total = prev_idle + prev_non_idle
564
+ total = idle + non_idle
565
+ totald = total - prev_total
566
+ idled = idle - prev_idle
567
+ return 0.0 if totald <= 0
568
+ ((totald - idled).to_f / totald) * 100
569
+ end
570
+
571
+ def cpu_cores_and_freq
572
+ return [0, []] unless File.exist?('/proc/cpuinfo')
573
+ cores = 0
574
+ freqs = []
575
+ File.foreach('/proc/cpuinfo') do |line|
576
+ cores += 1 if line =~ /^processor\s*:\s*\d+/
577
+ if line =~ /^cpu MHz\s*:\s*([\d.]+)/
578
+ freqs << $1.to_f
579
+ end
580
+ end
581
+ [cores, freqs.first(cores)]
582
+ rescue
583
+ [0, []]
584
+ end
585
+
586
+ def cpu_info
587
+ usage = 0.0
588
+ cores = 0
589
+ freq_display = "N/A"
590
+
591
+ case os_type
592
+ when :linux
593
+ prev = read_cpu_times
594
+ sleep 0.05
595
+ current = read_cpu_times
596
+ usage = calculate_cpu_usage(prev, current).round(1)
597
+ cores, freqs = cpu_cores_and_freq
598
+ freq_display = freqs.empty? ? "N/A" : freqs.map { |f| "#{f.round(0)}MHz" }.join(', ')
599
+ else
600
+ cores = (`sysctl -n hw.ncpu 2>/dev/null`.to_i rescue 0)
601
+ raw_freq_hz = (`sysctl -n hw.cpufrequency 2>/dev/null`.to_i rescue 0)
602
+ freq_display = if raw_freq_hz > 0
603
+ mhz = (raw_freq_hz.to_f / 1_000_000.0).round(0)
604
+ "#{mhz.to_i}MHz"
605
+ else
606
+ "N/A"
607
+ end
608
+ usage = begin
609
+ ps_output = `ps -A -o %cpu 2>/dev/null`
610
+ lines = ps_output.lines
611
+ values = lines[1..-1] || []
612
+ sum = values.map { |l| l.to_f }.inject(0.0, :+)
613
+ cores > 0 ? (sum / cores).round(1) : sum.round(1)
614
+ rescue
615
+ 0.0
616
+ end
617
+ end
618
+
619
+ "#{ui('CPU',:ui_key)} #{color("#{usage}%", t(:warn))} | " \
620
+ "#{ui('Cores',:ui_key)} #{color(cores.to_s, t(:ok))} | " \
621
+ "#{ui('Freq',:ui_key)} #{color(freq_display, t(:ui_title))}"
622
+ end
623
+
624
+ def ram_info
625
+ case os_type
626
+ when :linux
627
+ if File.exist?('/proc/meminfo')
628
+ meminfo = {}
629
+ File.read('/proc/meminfo').each_line do |line|
630
+ key, val = line.split(':')
631
+ meminfo[key.strip] = val.strip.split.first.to_i * 1024 if key && val
632
+ end
633
+ total = meminfo['MemTotal'] || 0
634
+ free = (meminfo['MemFree'] || 0) + (meminfo['Buffers'] || 0) + (meminfo['Cached'] || 0)
635
+ used = total - free
636
+ "#{ui('RAM',:ui_key)} #{color(human_bytes(used), t(:warn))} / #{color(human_bytes(total), t(:ok))}"
637
+ else
638
+ "#{ui('RAM',:ui_key)} Info not available"
639
+ end
640
+ else
641
+ begin
642
+ if os_type == :mac
643
+ total = `sysctl -n hw.memsize 2>/dev/null`.to_i
644
+ return "#{ui('RAM',:ui_key)} Info not available" if total <= 0
645
+ vm = `vm_stat 2>/dev/null`
646
+ page_size = vm[/page size of (\d+) bytes/, 1].to_i
647
+ page_size = 4096 if page_size <= 0
648
+ stats = {}
649
+ vm.each_line do |line|
650
+ if line =~ /^(.+):\s+(\d+)\./
651
+ stats[$1] = $2.to_i
652
+ end
653
+ end
654
+ used_pages = 0
655
+ %w[Pages active Pages wired down Pages occupied by compressor].each do |k|
656
+ used_pages += stats[k].to_i
657
+ end
658
+ used = used_pages * page_size
659
+ "#{ui('RAM',:ui_key)} #{color(human_bytes(used), t(:warn))} / #{color(human_bytes(total), t(:ok))}"
660
+ else
661
+ total = `sysctl -n hw.physmem 2>/dev/null`.to_i
662
+ total = `sysctl -n hw.realmem 2>/dev/null`.to_i if total <= 0
663
+ return "#{ui('RAM',:ui_key)} Info not available" if total <= 0
664
+ "#{ui('RAM',:ui_key)} #{color('Unknown', t(:warn))} / #{color(human_bytes(total), t(:ok))}"
665
+ end
666
+ rescue
667
+ "#{ui('RAM',:ui_key)} Info not available"
668
+ end
669
+ end
670
+ end
671
+
672
+ def storage_info
673
+ begin
674
+ require 'sys/filesystem'
675
+ stat = Sys::Filesystem.stat(Dir.pwd)
676
+ total = stat.bytes_total
677
+ free = stat.bytes_available
678
+ used = total - free
679
+ "#{ui("Disk(#{Dir.pwd})",:ui_key)} #{color(human_bytes(used), t(:warn))} / #{color(human_bytes(total), t(:ok))}"
680
+ rescue LoadError
681
+ "#{color("Install 'sys-filesystem' gem:", t(:err))} #{color('gem install sys-filesystem', t(:warn))}"
682
+ rescue
683
+ "#{ui('Disk',:ui_key)} Info not available"
684
+ end
685
+ end
686
+
687
+ # ---------------- Pretty column printer for colored text (used by ls) --------
688
+ def print_columns_colored(labels)
689
+ return if labels.nil? || labels.empty?
690
+ width = terminal_width
691
+ visible_lengths = labels.map { |s| strip_ansi(s).length }
692
+ max_len = visible_lengths.max || 0
693
+ col_width = [max_len + 2, 4].max
694
+ cols = [width / col_width, 1].max
695
+ rows = (labels.length.to_f / cols).ceil
696
+
697
+ rows.times do |r|
698
+ line = ""
699
+ cols.times do |c|
700
+ idx = c * rows + r
701
+ break if idx >= labels.length
702
+ label = labels[idx]
703
+ visible = strip_ansi(label).length
704
+ padding = col_width - visible
705
+ line << label << (" " * padding)
706
+ end
707
+ STDOUT.print("\r")
708
+ STDOUT.print(line.rstrip)
709
+ STDOUT.print("\n")
710
+ end
711
+ end
712
+
713
+ def builtin_ls(path = ".")
714
+ begin
715
+ entries = Dir.children(path).sort
716
+ rescue => e
717
+ puts color("ls: #{e.message}", t(:err))
718
+ return
719
+ end
720
+
721
+ labels = entries.map do |name|
722
+ full = File.join(path, name)
723
+ begin
724
+ if File.directory?(full)
725
+ color("#{name}/", t(:ui_hdr))
726
+ elsif File.executable?(full)
727
+ color("#{name}*", t(:ok))
728
+ else
729
+ color(name, t(:ui_val))
730
+ end
731
+ rescue
732
+ name
733
+ end
734
+ end
735
+
736
+ print_columns_colored(labels)
737
+ end
738
+
739
+ # ---------------- RSH ----------------
740
+ # Design: Ruby-ish feel (end blocks, keywords), but separate expr engine (no Ruby eval)
741
+
742
+ module Rsh
743
+ Token = Struct.new(:type, :value)
744
+
745
+ class Lexer
746
+ def initialize(src)
747
+ @s = src.to_s
748
+ @i = 0
749
+ @n = @s.length
750
+ end
751
+
752
+ def next_token
753
+ skip_ws
754
+ return Token.new(:eof, nil) if eof?
755
+ ch = peek
756
+
757
+ # numbers
758
+ if ch =~ /[0-9]/
759
+ return read_number
760
+ end
761
+
762
+ # strings
763
+ if ch == '"' || ch == "'"
764
+ return read_string
765
+ end
766
+
767
+ # vars: $?, $1, $NAME
768
+ if ch == '$'
769
+ advance
770
+ return Token.new(:var, '?') if peek == '?'
771
+ if peek =~ /[0-9]/
772
+ num = read_digits
773
+ return Token.new(:pos, num.to_i)
774
+ end
775
+ ident = read_ident
776
+ return Token.new(:var, ident)
777
+ end
778
+
779
+ # punctuation
780
+ if ch == '('
781
+ advance
782
+ return Token.new(:lparen, '(')
783
+ end
784
+ if ch == ')'
785
+ advance
786
+ return Token.new(:rparen, ')')
787
+ end
788
+ if ch == ','
789
+ advance
790
+ return Token.new(:comma, ',')
791
+ end
792
+
793
+ # operators (multi-char first)
794
+ two = @s[@i, 2]
795
+ three = @s[@i, 3]
796
+
797
+ if %w[== != <= >= && || ..].include?(two)
798
+ @i += 2
799
+ return Token.new(:op, two)
800
+ end
801
+
802
+ if %w[===].include?(three)
803
+ @i += 3
804
+ return Token.new(:op, three)
805
+ end
806
+
807
+ if %w[+ - * / % < > !].include?(ch)
808
+ advance
809
+ return Token.new(:op, ch)
810
+ end
811
+
812
+ # identifiers / keywords
813
+ if ch =~ /[A-Za-z_]/
814
+ ident = read_ident
815
+ type = case ident
816
+ when 'and', 'or', 'not' then :op
817
+ when 'true' then :bool
818
+ when 'false' then :bool
819
+ when 'nil' then :nil
820
+ else :ident
821
+ end
822
+ return Token.new(type, ident)
823
+ end
824
+
825
+ # unknown: treat as operator-ish single
826
+ advance
827
+ Token.new(:op, ch)
828
+ end
829
+
830
+ private
831
+
832
+ def eof?
833
+ @i >= @n
834
+ end
835
+
836
+ def peek
837
+ @s[@i]
838
+ end
839
+
840
+ def advance
841
+ @i += 1
842
+ end
843
+
844
+ def skip_ws
845
+ while !eof? && @s[@i] =~ /\s/
846
+ @i += 1
847
+ end
848
+ end
849
+
850
+ def read_digits
851
+ start = @i
852
+ while !eof? && @s[@i] =~ /[0-9]/
853
+ @i += 1
854
+ end
855
+ @s[start...@i]
856
+ end
857
+
858
+ def read_number
859
+ start = @i
860
+ read_digits
861
+ if !eof? && @s[@i] == '.' && @s[@i+1] =~ /[0-9]/
862
+ @i += 1
863
+ read_digits
864
+ end
865
+ Token.new(:num, @s[start...@i])
866
+ end
867
+
868
+ def read_ident
869
+ start = @i
870
+ while !eof? && @s[@i] =~ /[A-Za-z0-9_]/
871
+ @i += 1
872
+ end
873
+ @s[start...@i]
874
+ end
875
+
876
+ def read_string
877
+ quote = peek
878
+ advance
879
+ out = +""
880
+ while !eof?
881
+ ch = peek
882
+ advance
883
+ break if ch == quote
884
+ if ch == '\\' && !eof?
885
+ nxt = peek
886
+ advance
887
+ out << case nxt
888
+ when 'n' then "\n"
889
+ when 't' then "\t"
890
+ when 'r' then "\r"
891
+ when '"' then '"'
892
+ when "'" then "'"
893
+ when '\\' then '\\'
894
+ else nxt
895
+ end
896
+ else
897
+ out << ch
898
+ end
899
+ end
900
+ Token.new(:str, out)
901
+ end
902
+ end
903
+
904
+ class Parser
905
+ def initialize(src)
906
+ @lex = Lexer.new(src)
907
+ @tok = @lex.next_token
908
+ end
909
+
910
+ def parse
911
+ expr(0)
912
+ end
913
+
914
+ private
915
+
916
+ PRECEDENCE = {
917
+ 'or' => 1, '||' => 1,
918
+ 'and' => 2, '&&' => 2,
919
+ '==' => 3, '!=' => 3, '<' => 3, '<=' => 3, '>' => 3, '>=' => 3,
920
+ '..' => 4,
921
+ '+' => 5, '-' => 5,
922
+ '*' => 6, '/' => 6, '%' => 6,
923
+ }
924
+
925
+ def lbp(op)
926
+ PRECEDENCE[op] || 0
927
+ end
928
+
929
+ def advance
930
+ @tok = @lex.next_token
931
+ end
932
+
933
+ def expect(type)
934
+ t = @tok
935
+ raise "Expected #{type}, got #{t.type}" unless t.type == type
936
+ advance
937
+ t
938
+ end
939
+
940
+ def expr(rbp)
941
+ t = @tok
942
+ advance
943
+ left = nud(t)
944
+ while @tok.type == :op && lbp(@tok.value) > rbp
945
+ op = @tok.value
946
+ advance
947
+ left = [:bin, op, left, expr(lbp(op))]
948
+ end
949
+ left
950
+ end
951
+
952
+ def nud(t)
953
+ case t.type
954
+ when :num
955
+ if t.value.include?('.')
956
+ [:num, t.value.to_f]
957
+ else
958
+ [:num, t.value.to_i]
959
+ end
960
+ when :str
961
+ [:str, t.value]
962
+ when :bool
963
+ [:bool, t.value == 'true']
964
+ when :nil
965
+ [:nil, nil]
966
+ when :var
967
+ [:var, t.value]
968
+ when :pos
969
+ [:pos, t.value]
970
+ when :ident
971
+ # function call or identifier value
972
+ if @tok.type == :lparen
973
+ advance
974
+ args = []
975
+ if @tok.type != :rparen
976
+ loop do
977
+ args << expr(0)
978
+ break if @tok.type == :rparen
979
+ expect(:comma)
980
+ end
981
+ end
982
+ expect(:rparen)
983
+ [:call, t.value, args]
984
+ else
985
+ [:ident, t.value]
986
+ end
987
+ when :op
988
+ if t.value == '-' || t.value == '!' || t.value == 'not'
989
+ [:un, t.value, expr(7)]
990
+ else
991
+ raise "Unexpected operator #{t.value}"
992
+ end
993
+ when :lparen
994
+ e = expr(0)
995
+ expect(:rparen)
996
+ e
997
+ else
998
+ raise "Unexpected token #{t.type}"
999
+ end
1000
+ end
1001
+ end
1002
+
1003
+ module Eval
1004
+ module_function
1005
+
1006
+ def truthy?(v)
1007
+ !(v.nil? || v == false)
1008
+ end
1009
+
1010
+ def to_num(v)
1011
+ return v if v.is_a?(Integer) || v.is_a?(Float)
1012
+ s = v.to_s.strip
1013
+ return 0 if s.empty?
1014
+ return s.to_i if s =~ /\A-?\d+\z/
1015
+ return s.to_f if s =~ /\A-?\d+(\.\d+)?\z/
1016
+ 0
1017
+ end
1018
+
1019
+ def to_s(v)
1020
+ v.nil? ? "" : v.to_s
1021
+ end
1022
+
1023
+ def cmp(a,b)
1024
+ if (a.is_a?(Integer) || a.is_a?(Float) || a.to_s =~ /\A-?\d+(\.\d+)?\z/) &&
1025
+ (b.is_a?(Integer) || b.is_a?(Float) || b.to_s =~ /\A-?\d+(\.\d+)?\z/)
1026
+ to_num(a) <=> to_num(b)
1027
+ else
1028
+ to_s(a) <=> to_s(b)
1029
+ end
1030
+ end
1031
+
1032
+ def env_lookup(name, positional, last_status)
1033
+ case name
1034
+ when '?' then last_status
1035
+ else
1036
+ ENV[name] || ""
1037
+ end
1038
+ end
1039
+
1040
+ def eval_ast(ast, positional:, last_status:)
1041
+ t = ast[0]
1042
+ case t
1043
+ when :num then ast[1]
1044
+ when :str then ast[1]
1045
+ when :bool then ast[1]
1046
+ when :nil then nil
1047
+ when :var
1048
+ env_lookup(ast[1].to_s, positional, last_status)
1049
+ when :pos
1050
+ (positional[ast[1].to_i] || "")
1051
+ when :ident
1052
+ ENV[ast[1].to_s] || ""
1053
+ when :un
1054
+ op, rhs = ast[1], eval_ast(ast[2], positional: positional, last_status: last_status)
1055
+ case op
1056
+ when '-' then -to_num(rhs)
1057
+ when '!', 'not'
1058
+ !truthy?(rhs)
1059
+ else
1060
+ nil
1061
+ end
1062
+ when :bin
1063
+ op = ast[1]
1064
+ if op == 'and' || op == '&&'
1065
+ l = eval_ast(ast[2], positional: positional, last_status: last_status)
1066
+ return false unless truthy?(l)
1067
+ r = eval_ast(ast[3], positional: positional, last_status: last_status)
1068
+ return truthy?(r)
1069
+ elsif op == 'or' || op == '||'
1070
+ l = eval_ast(ast[2], positional: positional, last_status: last_status)
1071
+ return true if truthy?(l)
1072
+ r = eval_ast(ast[3], positional: positional, last_status: last_status)
1073
+ return truthy?(r)
1074
+ end
1075
+
1076
+ a = eval_ast(ast[2], positional: positional, last_status: last_status)
1077
+ b = eval_ast(ast[3], positional: positional, last_status: last_status)
1078
+
1079
+ case op
1080
+ when '+'
1081
+ if (a.is_a?(Integer) || a.is_a?(Float)) && (b.is_a?(Integer) || b.is_a?(Float))
1082
+ a + b
1083
+ elsif a.to_s =~ /\A-?\d+(\.\d+)?\z/ && b.to_s =~ /\A-?\d+(\.\d+)?\z/
1084
+ to_num(a) + to_num(b)
1085
+ else
1086
+ to_s(a) + to_s(b)
1087
+ end
1088
+ when '-'
1089
+ to_num(a) - to_num(b)
1090
+ when '*'
1091
+ to_num(a) * to_num(b)
1092
+ when '/'
1093
+ den = to_num(b)
1094
+ den == 0 ? 0 : (to_num(a).to_f / den.to_f)
1095
+ when '%'
1096
+ den = to_num(b)
1097
+ den == 0 ? 0 : (to_num(a).to_i % den.to_i)
1098
+ when '..'
1099
+ to_s(a) + to_s(b)
1100
+ when '==' then cmp(a,b) == 0
1101
+ when '!=' then cmp(a,b) != 0
1102
+ when '<' then cmp(a,b) < 0
1103
+ when '<=' then cmp(a,b) <= 0
1104
+ when '>' then cmp(a,b) > 0
1105
+ when '>=' then cmp(a,b) >= 0
1106
+ else
1107
+ nil
1108
+ end
1109
+ when :call
1110
+ name = ast[1].to_s
1111
+ args = ast[2].map { |x| eval_ast(x, positional: positional, last_status: last_status) }
1112
+ call_fn(name, args, positional: positional, last_status: last_status)
1113
+ else
1114
+ nil
1115
+ end
1116
+ end
1117
+
1118
+ def call_fn(name, args, positional:, last_status:)
1119
+ case name
1120
+ when 'int' then to_num(args[0]).to_i
1121
+ when 'float' then to_num(args[0]).to_f
1122
+ when 'str' then to_s(args[0])
1123
+ when 'len' then to_s(args[0]).length
1124
+ when 'empty' then to_s(args[0]).empty?
1125
+ when 'contains' then to_s(args[0]).include?(to_s(args[1]))
1126
+ when 'starts' then to_s(args[0]).start_with?(to_s(args[1]))
1127
+ when 'ends' then to_s(args[0]).end_with?(to_s(args[1]))
1128
+ when 'env'
1129
+ key = to_s(args[0])
1130
+ ENV[key] || ""
1131
+ when 'rand'
1132
+ n = to_num(args[0]).to_i
1133
+ n = 1 if n <= 0
1134
+ Kernel.rand(n)
1135
+ when 'pick'
1136
+ return "" if args.empty?
1137
+ args[Kernel.rand(args.length)]
1138
+ when 'status'
1139
+ last_status
1140
+ else
1141
+ # unknown function: empty
1142
+ ""
1143
+ end
1144
+ rescue
1145
+ ""
1146
+ end
1147
+ end
1148
+
1149
+ # ---- Script parsing ----
1150
+ NodeCmd = Struct.new(:line)
1151
+ NodeIf = Struct.new(:cond, :then_nodes, :else_nodes)
1152
+ NodeWhile = Struct.new(:cond, :body)
1153
+ NodeTimes = Struct.new(:count, :body)
1154
+ NodeFn = Struct.new(:name, :args, :body)
1155
+
1156
+ def self.strip_comment(line)
1157
+ in_single = false
1158
+ in_double = false
1159
+ escaped = false
1160
+ i = 0
1161
+ while i < line.length
1162
+ ch = line[i]
1163
+ if escaped
1164
+ escaped = false
1165
+ elsif ch == '\\'
1166
+ escaped = true
1167
+ elsif ch == "'" && !in_double
1168
+ in_single = !in_single
1169
+ elsif ch == '"' && !in_single
1170
+ in_double = !in_double
1171
+ elsif ch == '#' && !in_single && !in_double
1172
+ return line[0...i]
1173
+ end
1174
+ i += 1
1175
+ end
1176
+ line
1177
+ end
1178
+
1179
+ def self.parse_program(lines)
1180
+ clean = lines.map { |l| strip_comment(l.to_s).rstrip }
1181
+ nodes, _, stop = parse_nodes(clean, 0, [])
1182
+ raise "Unexpected #{stop}" if stop
1183
+ nodes
1184
+ end
1185
+
1186
+ def self.parse_nodes(lines, idx, stop_words)
1187
+ nodes = []
1188
+ while idx < lines.length
1189
+ raw = lines[idx]
1190
+ idx += 1
1191
+ line = raw.to_s.strip
1192
+ next if line.empty?
1193
+
1194
+ if stop_words.include?(line)
1195
+ return [nodes, idx - 1, line]
1196
+ end
1197
+
1198
+ if line.start_with?("if ")
1199
+ cond = line[3..-1].to_s.strip
1200
+ then_nodes, idx2, stop = parse_nodes(lines, idx, ['else', 'end'])
1201
+ idx = idx2
1202
+ else_nodes = []
1203
+ if stop == 'else'
1204
+ else_nodes, idx3, stop2 = parse_nodes(lines, idx + 1, ['end'])
1205
+ idx = idx3
1206
+ raise "Unmatched if" unless stop2 == 'end'
1207
+ idx += 1
1208
+ elsif stop == 'end'
1209
+ idx += 1
1210
+ else
1211
+ raise "Unmatched if"
1212
+ end
1213
+ nodes << NodeIf.new(cond, then_nodes, else_nodes)
1214
+ next
1215
+ end
1216
+
1217
+ if line.start_with?("while ")
1218
+ cond = line[6..-1].to_s.strip
1219
+ body, idx2, stop = parse_nodes(lines, idx, ['end'])
1220
+ raise "Unmatched while" unless stop == 'end'
1221
+ idx = idx2 + 1
1222
+ nodes << NodeWhile.new(cond, body)
1223
+ next
1224
+ end
1225
+
1226
+ if line.start_with?("times ")
1227
+ count = line[6..-1].to_s.strip
1228
+ body, idx2, stop = parse_nodes(lines, idx, ['end'])
1229
+ raise "Unmatched times" unless stop == 'end'
1230
+ idx = idx2 + 1
1231
+ nodes << NodeTimes.new(count, body)
1232
+ next
1233
+ end
1234
+
1235
+ if line.start_with?("fn ")
1236
+ parts = line.split(/\s+/)
1237
+ name = parts[1]
1238
+ args = parts[2..-1] || []
1239
+ body, idx2, stop = parse_nodes(lines, idx, ['end'])
1240
+ raise "Unmatched fn" unless stop == 'end'
1241
+ idx = idx2 + 1
1242
+ nodes << NodeFn.new(name, args, body)
1243
+ next
1244
+ end
1245
+
1246
+ nodes << NodeCmd.new(line)
1247
+ end
1248
+
1249
+ [nodes, idx, nil]
1250
+ end
1251
+ end
1252
+
1253
+ def eval_rsh_expr(expr)
1254
+ return false if expr.nil? || expr.to_s.strip.empty?
1255
+ ast = Rsh::Parser.new(expr).parse
1256
+ !!Rsh::Eval.eval_ast(ast, positional: $rsh_positional, last_status: $last_status)
1257
+ rescue
1258
+ false
1259
+ end
1260
+
1261
+ def run_rsh_nodes(nodes)
1262
+ nodes.each do |node|
1263
+ case node
1264
+ when Rsh::NodeCmd
1265
+ run_input_line(node.line)
1266
+ when Rsh::NodeIf
1267
+ if eval_rsh_expr(node.cond)
1268
+ run_rsh_nodes(node.then_nodes)
1269
+ else
1270
+ run_rsh_nodes(node.else_nodes)
1271
+ end
1272
+ when Rsh::NodeWhile
1273
+ while eval_rsh_expr(node.cond)
1274
+ begin
1275
+ run_rsh_nodes(node.body)
1276
+ rescue RshBreak
1277
+ break
1278
+ rescue RshContinue
1279
+ next
1280
+ end
1281
+ end
1282
+ when Rsh::NodeTimes
1283
+ count_ast = Rsh::Parser.new(node.count).parse
1284
+ n = Rsh::Eval.eval_ast(count_ast, positional: $rsh_positional, last_status: $last_status)
1285
+ times = n.to_i
1286
+ times = 0 if times < 0
1287
+ times.times do |i|
1288
+ ENV['it'] = i.to_s
1289
+ begin
1290
+ run_rsh_nodes(node.body)
1291
+ rescue RshBreak
1292
+ break
1293
+ rescue RshContinue
1294
+ next
1295
+ end
1296
+ end
1297
+ when Rsh::NodeFn
1298
+ # define function (stored as AST body)
1299
+ $rsh_functions[node.name] = { args: node.args, body: node.body }
1300
+ else
1301
+ # ignore
1302
+ end
1303
+ end
1304
+ end
1305
+
1306
+ def rsh_run_script(script_path, argv)
1307
+ $rsh_call_depth += 1
1308
+ raise "RSH call depth exceeded" if $rsh_call_depth > 200
1309
+
1310
+ saved_pos = $rsh_positional
1311
+ $rsh_positional = {}
1312
+ $rsh_positional[0] = File.basename(script_path)
1313
+ argv.each_with_index { |val, idx| $rsh_positional[idx + 1] = val.to_s }
1314
+
1315
+ raise "Script too large" if File.exist?(script_path) && File.size(script_path) > 2_000_000
1316
+
1317
+ lines = File.readlines(script_path, chomp: true)
1318
+ lines = lines[1..-1] || [] if lines[0] && lines[0].start_with?("#!")
1319
+ nodes = Rsh.parse_program(lines)
1320
+ run_rsh_nodes(nodes)
1321
+ ensure
1322
+ $rsh_positional = saved_pos
1323
+ $rsh_call_depth -= 1
1324
+ end
1325
+
1326
+ def rsh_call_function(name, argv)
1327
+ fn = $rsh_functions[name]
1328
+ return false unless fn
1329
+
1330
+ $rsh_call_depth += 1
1331
+ raise "RSH call depth exceeded" if $rsh_call_depth > 200
1332
+
1333
+ saved_positional = $rsh_positional
1334
+ $rsh_positional = {}
1335
+ $rsh_positional[0] = name
1336
+
1337
+ saved_env = {}
1338
+ fn[:args].each_with_index do |argname, idx|
1339
+ val = (argv[idx] || "").to_s
1340
+ saved_env[argname] = ENV.key?(argname) ? ENV[argname] : :__unset__
1341
+ ENV[argname] = val
1342
+ $rsh_positional[idx + 1] = val
1343
+ end
1344
+
1345
+ begin
1346
+ run_rsh_nodes(fn[:body])
1347
+ rescue RshReturn
1348
+ # swallow
1349
+ ensure
1350
+ saved_env.each do |k, v|
1351
+ if v == :__unset__
1352
+ ENV.delete(k)
1353
+ else
1354
+ ENV[k] = v
1355
+ end
1356
+ end
1357
+ $rsh_positional = saved_positional
1358
+ $rsh_call_depth -= 1
1359
+ end
1360
+ true
1361
+ end
1362
+
1363
+ # ---------------- Builtin registration + plugins ----------------
1364
+
1365
+ def register_builtin(name, &blk)
1366
+ $builtins[name.to_s] = blk
1367
+ end
1368
+
1369
+ def register_hook(type, &blk)
1370
+ $hooks[type.to_sym] << blk
1371
+ end
1372
+
1373
+ def run_hooks(type, *args)
1374
+ $hooks[type.to_sym].each do |blk|
1375
+ blk.call(*args)
1376
+ rescue
1377
+ end
1378
+ end
1379
+
1380
+ # Ruby plugin API object
1381
+ class SrshAPI
1382
+ def builtin(name, &blk) = register_builtin(name, &blk)
1383
+ def hook(type, &blk) = register_hook(type, &blk)
1384
+ def theme(name, hash) = ($themes[name.to_s] = hash.merge(name: name.to_s))
1385
+ def scheme(name) = set_theme!(name)
1386
+ def aliases = $aliases
1387
+ end
1388
+
1389
+ SRSH = SrshAPI.new
1390
+
1391
+ def load_plugins!
1392
+ # RSH plugins first
1393
+ Dir.glob(File.join(SRSH_PLUGINS_DIR, "*.rsh")).sort.each do |path|
1394
+ begin
1395
+ rsh_run_script(path, [])
1396
+ rescue => e
1397
+ STDERR.puts(color("plugin(rsh) #{File.basename(path)}: #{e.class}: #{e.message}", t(:err)))
1398
+ end
1399
+ end
1400
+
1401
+ # Ruby plugins (trusted)
1402
+ Dir.glob(File.join(SRSH_PLUGINS_DIR, "*.rb")).sort.each do |path|
1403
+ begin
1404
+ Kernel.load(path)
1405
+ rescue => e
1406
+ STDERR.puts(color("plugin(rb) #{File.basename(path)}: #{e.class}: #{e.message}", t(:err)))
1407
+ end
1408
+ end
1409
+ rescue
1410
+ end
1411
+
1412
+ # ---------------- External Execution Helper ----------------
1413
+
1414
+ def exec_external(args, stdin_file, stdout_file, append_out, stderr_file, append_err)
1415
+ command_path = args[0]
1416
+ if command_path && (command_path.include?('/') || command_path.start_with?('.'))
1417
+ begin
1418
+ if File.directory?(command_path)
1419
+ puts color("srsh: #{command_path}: is a directory", t(:err))
1420
+ $last_status = 126
1421
+ return
1422
+ end
1423
+ rescue
1424
+ end
1425
+ end
1426
+
1427
+ pid = fork do
1428
+ Signal.trap("INT","DEFAULT")
1429
+ if stdin_file
1430
+ STDIN.reopen(File.open(stdin_file,'r')) rescue nil
1431
+ end
1432
+ if stdout_file
1433
+ STDOUT.reopen(File.open(stdout_file, append_out ? 'a' : 'w')) rescue nil
1434
+ end
1435
+ if stderr_file
1436
+ STDERR.reopen(File.open(stderr_file, append_err ? 'a' : 'w')) rescue nil
1437
+ end
1438
+
1439
+ begin
1440
+ exec(*args)
1441
+ rescue Errno::ENOENT
1442
+ STDERR.puts color("Command not found: #{args[0]}", t(:warn))
1443
+ exit 127
1444
+ rescue Errno::EACCES
1445
+ STDERR.puts color("Permission denied: #{args[0]}", t(:err))
1446
+ exit 126
1447
+ end
1448
+ end
1449
+
1450
+ $child_pids << pid
1451
+ begin
1452
+ Process.wait(pid)
1453
+ $last_status = $?.exitstatus || 0
1454
+ rescue Interrupt
1455
+ ensure
1456
+ $child_pids.delete(pid)
1457
+ end
1458
+ end
1459
+
1460
+ # ---------------- Builtins ----------------
1461
+
1462
+ def builtin_help
1463
+ border = ui('=' * 66, :ui_border)
1464
+ puts border
1465
+ puts ui("srsh #{SRSH_VERSION}", :ui_title) + " " + ui("Builtins", :ui_hdr)
1466
+ puts ui("Theme:", :ui_key) + " #{color($theme_name, t(:ui_title))}"
1467
+ puts ui("Tip:", :ui_key) + " use #{color('scheme --list', t(:warn))} then #{color('scheme NAME', t(:warn))}"
1468
+ puts ui('-' * 66, :ui_border)
1469
+
1470
+ groups = {
1471
+ "Core" => {
1472
+ "cd [dir]" => "Change directory",
1473
+ "pwd" => "Print working directory",
1474
+ "ls [dir]" => "List directory (pretty columns)",
1475
+ "exit | quit" => "Exit srsh",
1476
+ "put TEXT" => "Print text",
1477
+ },
1478
+ "Config" => {
1479
+ "alias [name='cmd']" => "List or set aliases",
1480
+ "unalias NAME" => "Remove alias",
1481
+ "set [VAR [value...]]" => "List or set variables",
1482
+ "unset VAR" => "Unset a variable",
1483
+ "read VAR" => "Read a line into a variable",
1484
+ "source FILE [args...]" => "Run an RSH script",
1485
+ "scheme [--list|NAME]" => "List/set color scheme",
1486
+ "plugins" => "List loaded plugin files",
1487
+ "reload" => "Reload rc + plugins",
1488
+ },
1489
+ "Info" => {
1490
+ "systemfetch" => "Display system information",
1491
+ "jobs" => "Show tracked child jobs",
1492
+ "hist" => "Show history",
1493
+ "clearhist" => "Clear history (memory + file)",
1494
+ "help" => "Show this help",
1495
+ },
1496
+ "RSH control" => {
1497
+ "if EXPR ... [else ...] end" => "Conditional block",
1498
+ "while EXPR ... end" => "Loop",
1499
+ "times EXPR ... end" => "Loop N times (ENV['it']=index)",
1500
+ "fn NAME [args...] ... end" => "Define function",
1501
+ "break | continue | return" => "Control flow (scripts)",
1502
+ "true | false" => "Always succeed / fail",
1503
+ "sleep N" => "Sleep for N seconds",
1504
+ }
1505
+ }
1506
+
1507
+ col1 = 26
1508
+ groups.each do |g, cmds|
1509
+ puts ui("\n#{g}:", :ui_hdr)
1510
+ cmds.each do |k, v|
1511
+ left = k.ljust(col1)
1512
+ puts color(left, t(:ui_key)) + color(v, t(:ui_val))
1513
+ end
1514
+ end
1515
+
1516
+ puts "\n" + border
1517
+ end
1518
+
1519
+ def builtin_systemfetch
1520
+ user = (ENV['USER'] || Etc.getlogin || Etc.getpwuid.name rescue ENV['USER'] || Etc.getlogin)
1521
+ host = Socket.gethostname
1522
+ os = detect_distro
1523
+ ruby_ver = RUBY_VERSION
1524
+
1525
+ cpu_percent = begin
1526
+ case os_type
1527
+ when :linux
1528
+ prev = read_cpu_times
1529
+ sleep 0.05
1530
+ cur = read_cpu_times
1531
+ calculate_cpu_usage(prev, cur).round(1)
1532
+ else
1533
+ cores = `sysctl -n hw.ncpu 2>/dev/null`.to_i rescue 0
1534
+ ps_output = `ps -A -o %cpu 2>/dev/null`
1535
+ lines = ps_output.lines
1536
+ values = lines[1..-1] || []
1537
+ sum = values.map { |l| l.to_f }.inject(0.0, :+)
1538
+ cores > 0 ? (sum / cores).round(1) : sum.round(1)
1539
+ end
1540
+ rescue
1541
+ 0.0
1542
+ end
1543
+
1544
+ mem_percent = begin
1545
+ case os_type
1546
+ when :linux
1547
+ if File.exist?('/proc/meminfo')
1548
+ meminfo = {}
1549
+ File.read('/proc/meminfo').each_line do |line|
1550
+ k, v = line.split(':')
1551
+ meminfo[k.strip] = v.strip.split.first.to_i * 1024 if k && v
1552
+ end
1553
+ total = meminfo['MemTotal'] || 1
1554
+ free = (meminfo['MemAvailable'] || meminfo['MemFree'] || 0)
1555
+ used = total - free
1556
+ (used.to_f / total.to_f * 100).round(1)
1557
+ else
1558
+ 0.0
1559
+ end
1560
+ when :mac
1561
+ total = `sysctl -n hw.memsize 2>/dev/null`.to_i
1562
+ if total <= 0
1563
+ 0.0
1564
+ else
1565
+ vm = `vm_stat 2>/dev/null`
1566
+ page_size = vm[/page size of (\d+) bytes/, 1].to_i
1567
+ page_size = 4096 if page_size <= 0
1568
+ stats = {}
1569
+ vm.each_line do |line|
1570
+ if line =~ /^(.+):\s+(\d+)\./
1571
+ stats[$1] = $2.to_i
1572
+ end
1573
+ end
1574
+ used_pages = 0
1575
+ %w[Pages active Pages wired down Pages occupied by compressor].each { |k| used_pages += stats[k].to_i }
1576
+ used = used_pages * page_size
1577
+ ((used.to_f / total.to_f) * 100).round(1)
1578
+ end
1579
+ else
1580
+ 0.0
1581
+ end
1582
+ rescue
1583
+ 0.0
1584
+ end
1585
+
1586
+ border = ui('=' * 60, :ui_border)
1587
+ puts border
1588
+ puts ui("srsh System Information", :ui_title)
1589
+ puts ui("User: ", :ui_key) + color("#{user}@#{host}", t(:ui_val))
1590
+ puts ui("OS: ", :ui_key) + color(os, t(:ui_val))
1591
+ puts ui("Shell: ", :ui_key) + color("srsh v#{SRSH_VERSION}", t(:ui_val))
1592
+ puts ui("Ruby: ", :ui_key) + color(ruby_ver, t(:ui_val))
1593
+ puts ui("CPU: ", :ui_key) + nice_bar(cpu_percent / 100.0, 30, t(:ok))
1594
+ puts ui("RAM: ", :ui_key) + nice_bar(mem_percent / 100.0, 30, t(:prompt_mark))
1595
+ puts border
1596
+ end
1597
+
1598
+ def builtin_jobs
1599
+ if $child_pids.empty?
1600
+ puts color("No tracked child jobs.", t(:ui_hdr))
1601
+ return
1602
+ end
1603
+ $child_pids.each do |pid|
1604
+ status = begin
1605
+ Process.kill(0, pid)
1606
+ 'running'
1607
+ rescue Errno::ESRCH
1608
+ 'done'
1609
+ rescue Errno::EPERM
1610
+ 'running'
1611
+ end
1612
+ puts "[#{pid}] #{status}"
1613
+ end
1614
+ end
1615
+
1616
+ def builtin_hist
1617
+ HISTORY.each_with_index { |h, i| printf "%5d %s\n", i + 1, h }
1618
+ end
1619
+
1620
+ def builtin_clearhist
1621
+ HISTORY.clear
1622
+ File.delete(HISTORY_FILE) rescue nil
1623
+ puts color("History cleared (memory + file).", t(:ok))
1624
+ end
1625
+
1626
+ def builtin_scheme(args)
1627
+ a = args[1..-1] || []
1628
+ if a.empty?
1629
+ puts ui("Theme:", :ui_key) + " #{color($theme_name, t(:ui_title))}"
1630
+ return
1631
+ end
1632
+
1633
+ if a[0] == "--list" || a[0] == "-l"
1634
+ puts ui("Available schemes:", :ui_hdr)
1635
+ $themes.keys.sort.each do |k|
1636
+ mark = (k == $theme_name) ? color("*", t(:ok)) : " "
1637
+ puts " #{mark} #{k}"
1638
+ end
1639
+ puts ui("User themes dir:", :ui_key) + " #{SRSH_THEMES_DIR}"
1640
+ return
1641
+ end
1642
+
1643
+ name = a[0].to_s
1644
+ if set_theme!(name)
1645
+ puts color("scheme: now using '#{name}'", t(:ok))
1646
+ else
1647
+ puts color("scheme: unknown '#{name}' (try: scheme --list)", t(:err))
1648
+ $last_status = 1
1649
+ end
1650
+ end
1651
+
1652
+ def builtin_plugins
1653
+ rsh = Dir.glob(File.join(SRSH_PLUGINS_DIR, "*.rsh")).sort
1654
+ rb = Dir.glob(File.join(SRSH_PLUGINS_DIR, "*.rb")).sort
1655
+ puts ui("Plugins:", :ui_hdr)
1656
+ (rsh + rb).each { |p| puts " - #{File.basename(p)}" }
1657
+ puts ui("Dir:", :ui_key) + " #{SRSH_PLUGINS_DIR}"
1658
+ end
1659
+
1660
+ def builtin_reload
1661
+ begin
1662
+ rsh_run_script(RC_FILE, []) if File.exist?(RC_FILE)
1663
+ load_plugins!
1664
+ puts color("reloaded rc + plugins", t(:ok))
1665
+ rescue => e
1666
+ puts color("reload: #{e.class}: #{e.message}", t(:err))
1667
+ $last_status = 1
1668
+ end
1669
+ end
1670
+
1671
+ # register builtins
1672
+ register_builtin('help') { |args| builtin_help; $last_status = 0 }
1673
+ register_builtin('systemfetch') { |args| builtin_systemfetch; $last_status = 0 }
1674
+ register_builtin('jobs') { |args| builtin_jobs; $last_status = 0 }
1675
+ register_builtin('hist') { |args| builtin_hist; $last_status = 0 }
1676
+ register_builtin('clearhist') { |args| builtin_clearhist; $last_status = 0 }
1677
+ register_builtin('scheme') { |args| builtin_scheme(args); $last_status ||= 0 }
1678
+ register_builtin('plugins') { |args| builtin_plugins; $last_status = 0 }
1679
+ register_builtin('reload') { |args| builtin_reload; $last_status ||= 0 }
1680
+
1681
+ # ---------------- Command Execution ----------------
1682
+
1683
+ def run_command(cmd)
1684
+ cmd = cmd.to_s.strip
1685
+ return if cmd.empty?
1686
+
1687
+ cmd = expand_aliases(cmd)
1688
+ cmd = expand_vars(cmd)
1689
+
1690
+ run_hooks(:pre_cmd, cmd)
1691
+
1692
+ # ---- assignments ----
1693
+ # Shell-style: VAR=value (no spaces)
1694
+ if (m = cmd.match(/\A([A-Za-z_][A-Za-z0-9_]*)=(.*)\z/))
1695
+ var = m[1]
1696
+ val = m[2] || ""
1697
+ ENV[var] = val
1698
+ $last_status = 0
1699
+ run_hooks(:post_cmd, cmd, $last_status)
1700
+ return
1701
+ end
1702
+
1703
+ # Expr-style: VAR = expr (RSH expr, safe)
1704
+ if (m = cmd.match(/\A([A-Za-z_][A-Za-z0-9_]*)\s+=\s+(.+)\z/))
1705
+ var = m[1]
1706
+ rhs = m[2]
1707
+ begin
1708
+ ast = Rsh::Parser.new(rhs).parse
1709
+ val = Rsh::Eval.eval_ast(ast, positional: $rsh_positional, last_status: $last_status)
1710
+ ENV[var] = val.nil? ? "" : val.to_s
1711
+ $last_status = 0
1712
+ rescue
1713
+ ENV[var] = rhs
1714
+ $last_status = 0
1715
+ end
1716
+ run_hooks(:post_cmd, cmd, $last_status)
1717
+ return
1718
+ end
1719
+
1720
+ # ---- expression output ----
1721
+ # emit EXPR -> evaluate RSH expr and print it (keeps quotes intact)
1722
+ if (m = cmd.match(/\Aemit\s+(.+)\z/))
1723
+ expr = m[1]
1724
+ begin
1725
+ ast = Rsh::Parser.new(expr).parse
1726
+ val = Rsh::Eval.eval_ast(ast, positional: $rsh_positional, last_status: $last_status)
1727
+ puts(val.nil? ? "" : val.to_s)
1728
+ $last_status = 0
1729
+ rescue => e
1730
+ STDERR.puts color("emit: #{e.class}: #{e.message}", t('error'))
1731
+ $last_status = 1
1732
+ end
1733
+ run_hooks(:post_cmd, cmd, $last_status)
1734
+ return
1735
+ end
1736
+
1737
+
1738
+ # ---- redirections + args ----
1739
+ cmd2, stdin_file, stdout_file, append_out, stderr_file, append_err = parse_redirection(cmd)
1740
+ args = Shellwords.shellsplit(cmd2) rescue []
1741
+ return if args.empty?
1742
+
1743
+ # RSH functions
1744
+ if $rsh_functions.key?(args[0])
1745
+ ok = rsh_call_function(args[0], args[1..-1] || [])
1746
+ $last_status = ok ? 0 : 1
1747
+ run_hooks(:post_cmd, cmd, $last_status)
1748
+ return
1749
+ end
1750
+
1751
+ # Builtins
1752
+ if $builtins.key?(args[0])
1753
+ with_redirections(stdin_file, stdout_file, append_out, stderr_file, append_err) do
1754
+ begin
1755
+ $builtins[args[0]].call(args)
1756
+ rescue RshBreak
1757
+ raise
1758
+ rescue RshContinue
1759
+ raise
1760
+ rescue RshReturn
1761
+ raise
1762
+ rescue => e
1763
+ STDERR.puts color("#{args[0]}: #{e.class}: #{e.message}", t(:err))
1764
+ $last_status = 1
1765
+ end
1766
+ end
1767
+ run_hooks(:post_cmd, cmd, $last_status)
1768
+ return
1769
+ end
1770
+
1771
+ # Special-cased builtins that need args parsing
1772
+ case args[0]
1773
+ when 'ls'
1774
+ with_redirections(stdin_file, stdout_file, append_out, stderr_file, append_err) do
1775
+ if args.length == 1
1776
+ builtin_ls(".")
1777
+ $last_status = 0
1778
+ elsif args.length == 2 && !args[1].start_with?("-")
1779
+ builtin_ls(args[1])
1780
+ $last_status = 0
1781
+ else
1782
+ exec_external(args, stdin_file, stdout_file, append_out, stderr_file, append_err)
1783
+ end
1784
+ end
1785
+ run_hooks(:post_cmd, cmd, $last_status)
1786
+ return
1787
+
1788
+ when 'cd'
1789
+ path = args[1] ? File.expand_path(args[1]) : ENV['HOME']
1790
+ if !path || !File.exist?(path)
1791
+ puts color("cd: no such file or directory: #{args[1]}", t(:err))
1792
+ $last_status = 1
1793
+ elsif !File.directory?(path)
1794
+ puts color("cd: not a directory: #{args[1]}", t(:err))
1795
+ $last_status = 1
1796
+ else
1797
+ Dir.chdir(path)
1798
+ $last_status = 0
1799
+ end
1800
+ run_hooks(:post_cmd, cmd, $last_status)
1801
+ return
1802
+
1803
+ when 'pwd'
1804
+ puts color(Dir.pwd, t(:ui_hdr))
1805
+ $last_status = 0
1806
+ run_hooks(:post_cmd, cmd, $last_status)
1807
+ return
1808
+
1809
+ when 'exit', 'quit'
1810
+ $child_pids.each { |pid| Process.kill("TERM", pid) rescue nil }
1811
+ exit 0
1812
+
1813
+ when 'alias'
1814
+ if args[1].nil?
1815
+ $aliases.each { |k,v| puts "#{k}='#{v}'" }
1816
+ else
1817
+ arg = args[1..].join(' ')
1818
+ if arg =~ /^(\w+)=([\"']?)(.+?)\2$/
1819
+ $aliases[$1] = $3
1820
+ else
1821
+ puts color("Invalid alias format", t(:err))
1822
+ $last_status = 1
1823
+ run_hooks(:post_cmd, cmd, $last_status)
1824
+ return
1825
+ end
1826
+ end
1827
+ $last_status = 0
1828
+ run_hooks(:post_cmd, cmd, $last_status)
1829
+ return
1830
+
1831
+ when 'unalias'
1832
+ if args[1]
1833
+ $aliases.delete(args[1])
1834
+ $last_status = 0
1835
+ else
1836
+ puts color("unalias: usage: unalias name", t(:err))
1837
+ $last_status = 1
1838
+ end
1839
+ run_hooks(:post_cmd, cmd, $last_status)
1840
+ return
1841
+
1842
+ when 'put'
1843
+ msg = args[1..-1].join(' ')
1844
+ puts msg
1845
+ $last_status = 0
1846
+ run_hooks(:post_cmd, cmd, $last_status)
1847
+ return
1848
+
1849
+ when 'break'
1850
+ raise RshBreak
1851
+ when 'continue'
1852
+ raise RshContinue
1853
+ when 'return'
1854
+ raise RshReturn
1855
+
1856
+ when 'set'
1857
+ if args.length == 1
1858
+ ENV.keys.sort.each { |k| puts "#{k}=#{ENV[k]}" }
1859
+ else
1860
+ var = args[1]
1861
+ val = args[2..-1].join(' ')
1862
+ ENV[var] = val
1863
+ end
1864
+ $last_status = 0
1865
+ run_hooks(:post_cmd, cmd, $last_status)
1866
+ return
1867
+
1868
+ when 'unset'
1869
+ if args[1]
1870
+ ENV.delete(args[1])
1871
+ $last_status = 0
1872
+ else
1873
+ puts color("unset: usage: unset VAR", t(:err))
1874
+ $last_status = 1
1875
+ end
1876
+ run_hooks(:post_cmd, cmd, $last_status)
1877
+ return
1878
+
1879
+ when 'read'
1880
+ var = args[1]
1881
+ unless var
1882
+ puts color("read: usage: read VAR", t(:err))
1883
+ $last_status = 1
1884
+ run_hooks(:post_cmd, cmd, $last_status)
1885
+ return
1886
+ end
1887
+ line = STDIN.gets
1888
+ ENV[var] = (line ? line.chomp : "")
1889
+ $last_status = 0
1890
+ run_hooks(:post_cmd, cmd, $last_status)
1891
+ return
1892
+
1893
+ when 'true'
1894
+ $last_status = 0
1895
+ run_hooks(:post_cmd, cmd, $last_status)
1896
+ return
1897
+
1898
+ when 'false'
1899
+ $last_status = 1
1900
+ run_hooks(:post_cmd, cmd, $last_status)
1901
+ return
1902
+
1903
+ when 'sleep'
1904
+ secs = (args[1] || "1").to_f
1905
+ begin
1906
+ Kernel.sleep(secs)
1907
+ $last_status = 0
1908
+ rescue
1909
+ $last_status = 1
1910
+ end
1911
+ run_hooks(:post_cmd, cmd, $last_status)
1912
+ return
1913
+
1914
+ when 'source', '.'
1915
+ file = args[1]
1916
+ if file.nil?
1917
+ puts color("source: usage: source FILE", t(:err))
1918
+ $last_status = 1
1919
+ run_hooks(:post_cmd, cmd, $last_status)
1920
+ return
1921
+ end
1922
+ begin
1923
+ rsh_run_script(file, args[2..-1] || [])
1924
+ $last_status = 0
1925
+ rescue => e
1926
+ STDERR.puts color("source error: #{e.class}: #{e.message}", t(:err))
1927
+ $last_status = 1
1928
+ end
1929
+ run_hooks(:post_cmd, cmd, $last_status)
1930
+ return
1931
+ end
1932
+
1933
+ # Fallback to external command
1934
+ exec_external(args, stdin_file, stdout_file, append_out, stderr_file, append_err)
1935
+ run_hooks(:post_cmd, cmd, $last_status)
1936
+ end
1937
+
1938
+ # ---------------- Chained Commands ----------------
1939
+ # supports ;, &&, || (real short-circuit)
1940
+
1941
+ def split_commands_ops(input)
1942
+ return [] if input.nil?
1943
+
1944
+ tokens = []
1945
+ buf = +""
1946
+ in_single = false
1947
+ in_double = false
1948
+ escaped = false
1949
+ i = 0
1950
+
1951
+ push = lambda do |op|
1952
+ cmd = buf.strip
1953
+ tokens << [op, cmd] unless cmd.empty?
1954
+ buf = +""
1955
+ end
1956
+
1957
+ current_op = :seq
1958
+
1959
+ while i < input.length
1960
+ ch = input[i]
1961
+
1962
+ if escaped
1963
+ buf << ch
1964
+ escaped = false
1965
+ elsif ch == '\\'
1966
+ escaped = true
1967
+ buf << ch
1968
+ elsif ch == "'" && !in_double
1969
+ in_single = !in_single
1970
+ buf << ch
1971
+ elsif ch == '"' && !in_single
1972
+ in_double = !in_double
1973
+ buf << ch
1974
+ elsif !in_single && !in_double
1975
+ if ch == ';'
1976
+ push.call(current_op)
1977
+ current_op = :seq
1978
+ elsif ch == '&' && input[i+1] == '&'
1979
+ push.call(current_op)
1980
+ current_op = :and
1981
+ i += 1
1982
+ elsif ch == '|' && input[i+1] == '|'
1983
+ push.call(current_op)
1984
+ current_op = :or
1985
+ i += 1
1986
+ else
1987
+ buf << ch
1988
+ end
1989
+ else
1990
+ buf << ch
1991
+ end
1992
+
1993
+ i += 1
1994
+ end
1995
+
1996
+ push.call(current_op)
1997
+ tokens
1998
+ end
1999
+
2000
+ def run_input_line(input)
2001
+ split_commands_ops(input).each do |op, cmd|
2002
+ case op
2003
+ when :seq
2004
+ run_command(cmd)
2005
+ when :and
2006
+ run_command(cmd) if $last_status == 0
2007
+ when :or
2008
+ run_command(cmd) if $last_status != 0
2009
+ end
2010
+ end
2011
+ end
2012
+
2013
+ # ---------------- Prompt ----------------
2014
+
2015
+ def prompt(hostname)
2016
+ "#{color(Dir.pwd, t(:prompt_path))} #{color(hostname, t(:prompt_host))}#{color(' > ', t(:prompt_mark))}"
2017
+ end
2018
+
2019
+ # ---------------- Ghost + Completion Helpers ----------------
2020
+
2021
+ def history_ghost_for(line)
2022
+ return nil if line.nil? || line.empty?
2023
+ HISTORY.reverse_each do |h|
2024
+ next if h.nil? || h.empty?
2025
+ next if h.start_with?("[completions:")
2026
+ next unless h.start_with?(line)
2027
+ next if h == line
2028
+ return h
2029
+ end
2030
+ nil
2031
+ end
2032
+
2033
+ # exec cache to keep tab completion fast
2034
+ class ExecCache
2035
+ def initialize
2036
+ @path = nil
2037
+ @entries = []
2038
+ @built_at = 0.0
2039
+ end
2040
+
2041
+ def list
2042
+ p = ENV['PATH'].to_s
2043
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
2044
+ if @path != p || (now - @built_at) > 2.5
2045
+ @path = p
2046
+ @built_at = now
2047
+ @entries = build_execs(p)
2048
+ end
2049
+ @entries
2050
+ end
2051
+
2052
+ private
2053
+
2054
+ def build_execs(path)
2055
+ out = []
2056
+ path.split(':').each do |dir|
2057
+ next if dir.nil? || dir.empty?
2058
+ begin
2059
+ Dir.children(dir).each do |entry|
2060
+ full = File.join(dir, entry)
2061
+ next if File.directory?(full)
2062
+ next unless File.executable?(full)
2063
+ out << entry
2064
+ end
2065
+ rescue
2066
+ end
2067
+ end
2068
+ out.uniq
2069
+ end
2070
+ end
2071
+
2072
+ $exec_cache = ExecCache.new
2073
+
2074
+ def tab_completions_for(prefix, first_word, at_first_word)
2075
+ prefix ||= ""
2076
+
2077
+ dir = "."
2078
+ base = prefix
2079
+
2080
+ if prefix.include?('/')
2081
+ if prefix.end_with?('/')
2082
+ dir = prefix.chomp('/')
2083
+ base = ""
2084
+ else
2085
+ dir = File.dirname(prefix)
2086
+ base = File.basename(prefix)
2087
+ end
2088
+ dir = "." if dir.nil? || dir.empty?
2089
+ end
2090
+
2091
+ file_completions = []
2092
+ if Dir.exist?(dir)
2093
+ Dir.children(dir).each do |entry|
2094
+ next unless entry.start_with?(base)
2095
+ full = File.join(dir, entry)
2096
+
2097
+ rel = if dir == "."
2098
+ entry
2099
+ else
2100
+ File.join(File.dirname(prefix), entry)
2101
+ end
2102
+
2103
+ case first_word
2104
+ when "cd"
2105
+ next unless File.directory?(full)
2106
+ rel = rel + "/" unless rel.end_with?("/")
2107
+ file_completions << rel
2108
+ when "cat"
2109
+ next unless File.file?(full)
2110
+ file_completions << rel
2111
+ else
2112
+ rel = rel + "/" if File.directory?(full) && !rel.end_with?("/")
2113
+ file_completions << rel
2114
+ end
2115
+ end
2116
+ end
2117
+
2118
+ exec_completions = []
2119
+ if first_word != "cat" && first_word != "cd" && at_first_word && !prefix.include?('/')
2120
+ exec_completions = $exec_cache.list.grep(/^#{Regexp.escape(prefix)}/)
2121
+ end
2122
+
2123
+ (file_completions + exec_completions).uniq
2124
+ end
2125
+
2126
+ def longest_common_prefix(strings)
2127
+ return "" if strings.empty?
2128
+ shortest = strings.min_by(&:length)
2129
+ shortest.length.times do |i|
2130
+ c = shortest[i]
2131
+ strings.each { |s| return shortest[0...i] if s[i] != c }
2132
+ end
2133
+ shortest
2134
+ end
2135
+
2136
+ def render_line(prompt_str, buffer, cursor, show_ghost = true)
2137
+ buffer = buffer || ""
2138
+ cursor = [[cursor, 0].max, buffer.length].min
2139
+
2140
+ ghost_tail = ""
2141
+ if show_ghost && cursor == buffer.length
2142
+ suggestion = history_ghost_for(buffer)
2143
+ ghost_tail = suggestion ? suggestion[buffer.length..-1].to_s : ""
2144
+ end
2145
+
2146
+ width = terminal_width
2147
+ prompt_vis = strip_ansi(prompt_str).length
2148
+ total_vis = prompt_vis + buffer.length + ghost_tail.length
2149
+ rows = [(total_vis.to_f / width).ceil, 1].max
2150
+
2151
+ # Clear previous render block (only what we drew last time)
2152
+ if $last_render_rows && $last_render_rows > 0
2153
+ STDOUT.print("\r")
2154
+ ($last_render_rows - 1).times { STDOUT.print("\e[1A\r") }
2155
+ $last_render_rows.times do |i|
2156
+ STDOUT.print("\e[0K")
2157
+ STDOUT.print("\n") if i < $last_render_rows - 1
2158
+ end
2159
+ ($last_render_rows - 1).times { STDOUT.print("\e[1A\r") }
2160
+ end
2161
+
2162
+ STDOUT.print("\r")
2163
+ STDOUT.print(prompt_str)
2164
+ STDOUT.print(buffer)
2165
+
2166
+ # explicit gray, not "dim" (fixes Konsole/Breeze showing white)
2167
+ STDOUT.print(color(ghost_tail, t(:dim))) unless ghost_tail.empty?
2168
+
2169
+ move_left = ghost_tail.length + (buffer.length - cursor)
2170
+ STDOUT.print("\e[#{move_left}D") if move_left > 0
2171
+ STDOUT.flush
2172
+
2173
+ $last_render_rows = rows
2174
+ end
2175
+
2176
+ # --------- MULTI-COLUMN TAB LIST ----------
2177
+ def print_tab_list(comps)
2178
+ return if comps.empty?
2179
+
2180
+ width = terminal_width
2181
+ max_len = comps.map(&:length).max || 0
2182
+ col_width = [max_len + 2, 4].max
2183
+ cols = [width / col_width, 1].max
2184
+ rows = (comps.length.to_f / cols).ceil
2185
+
2186
+ STDOUT.print("\r\n")
2187
+ rows.times do |r|
2188
+ line = ""
2189
+ cols.times do |c|
2190
+ idx = c * rows + r
2191
+ break if idx >= comps.length
2192
+ item = comps[idx]
2193
+ padding = col_width - item.length
2194
+ line << item << (" " * padding)
2195
+ end
2196
+ STDOUT.print("\r")
2197
+ STDOUT.print(line.rstrip)
2198
+ STDOUT.print("\n")
2199
+ end
2200
+ STDOUT.print("\r\n")
2201
+ STDOUT.flush
2202
+ end
2203
+
2204
+ def handle_tab_completion(prompt_str, buffer, cursor, last_tab_prefix)
2205
+ buffer = buffer || ""
2206
+ cursor = [[cursor, 0].max, buffer.length].min
2207
+
2208
+ wstart = buffer.rindex(/[ \t]/, cursor - 1) || -1
2209
+ wstart += 1
2210
+ prefix = buffer[wstart...cursor] || ""
2211
+
2212
+ before_word = buffer[0...wstart]
2213
+ at_first_word = before_word.strip.empty?
2214
+ first_word = buffer.strip.split(/\s+/, 2)[0] || ""
2215
+
2216
+ comps = tab_completions_for(prefix, first_word, at_first_word)
2217
+ return [buffer, cursor, nil, false] if comps.empty?
2218
+
2219
+ if comps.size == 1
2220
+ new_word = comps.first
2221
+ buffer = buffer[0...wstart] + new_word + buffer[cursor..-1].to_s
2222
+ cursor = wstart + new_word.length
2223
+ return [buffer, cursor, nil, true]
2224
+ end
2225
+
2226
+ if prefix != last_tab_prefix
2227
+ lcp = longest_common_prefix(comps)
2228
+ if lcp && lcp.length > prefix.length
2229
+ buffer = buffer[0...wstart] + lcp + buffer[cursor..-1].to_s
2230
+ cursor = wstart + lcp.length
2231
+ else
2232
+ STDOUT.print("\a")
2233
+ end
2234
+ return [buffer, cursor, prefix, false]
2235
+ end
2236
+
2237
+ render_line(prompt_str, buffer, cursor, false)
2238
+ print_tab_list(comps)
2239
+ [buffer, cursor, prefix, true]
2240
+ end
2241
+
2242
+ def read_line_with_ghost(prompt_str)
2243
+ buffer = ""
2244
+ cursor = 0
2245
+ hist_index = HISTORY.length
2246
+ saved_line_for_history = ""
2247
+ last_tab_prefix = nil
2248
+
2249
+ render_line(prompt_str, buffer, cursor)
2250
+
2251
+ status = :ok
2252
+
2253
+ IO.console.raw do |io|
2254
+ loop do
2255
+ ch = io.getch
2256
+
2257
+ case ch
2258
+ when "\r", "\n"
2259
+ cursor = buffer.length
2260
+ render_line(prompt_str, buffer, cursor, false)
2261
+ STDOUT.print("\r\n")
2262
+ STDOUT.flush
2263
+ break
2264
+
2265
+ when "\u0003" # Ctrl-C
2266
+ STDOUT.print("^C\r\n")
2267
+ STDOUT.flush
2268
+ status = :interrupt
2269
+ buffer = ""
2270
+ break
2271
+
2272
+ when "\u0004" # Ctrl-D
2273
+ if buffer.empty?
2274
+ status = :eof
2275
+ buffer = nil
2276
+ STDOUT.print("\r\n")
2277
+ STDOUT.flush
2278
+ break
2279
+ end
2280
+
2281
+ when "\u0001" # Ctrl-A
2282
+ cursor = 0
2283
+ last_tab_prefix = nil
2284
+
2285
+ when "\u0005" # Ctrl-E
2286
+ cursor = buffer.length
2287
+ last_tab_prefix = nil
2288
+
2289
+ when "\u007F", "\b" # Backspace
2290
+ if cursor > 0
2291
+ buffer.slice!(cursor - 1)
2292
+ cursor -= 1
2293
+ end
2294
+ last_tab_prefix = nil
2295
+
2296
+ when "\t" # Tab completion
2297
+ buffer, cursor, last_tab_prefix, printed =
2298
+ handle_tab_completion(prompt_str, buffer, cursor, last_tab_prefix)
2299
+ $last_render_rows = 1 if printed
2300
+
2301
+ when "\e" # Escape sequences (arrows, home/end)
2302
+ seq1 = io.getch
2303
+ seq2 = io.getch
2304
+ if seq1 == "[" && seq2
2305
+ case seq2
2306
+ when "A" # Up
2307
+ if hist_index == HISTORY.length
2308
+ saved_line_for_history = buffer.dup
2309
+ end
2310
+ if hist_index > 0
2311
+ hist_index -= 1
2312
+ buffer = HISTORY[hist_index] || ""
2313
+ cursor = buffer.length
2314
+ end
2315
+ when "B" # Down
2316
+ if hist_index < HISTORY.length - 1
2317
+ hist_index += 1
2318
+ buffer = HISTORY[hist_index] || ""
2319
+ cursor = buffer.length
2320
+ elsif hist_index == HISTORY.length - 1
2321
+ hist_index = HISTORY.length
2322
+ buffer = saved_line_for_history || ""
2323
+ cursor = buffer.length
2324
+ end
2325
+ when "C" # Right
2326
+ if cursor < buffer.length
2327
+ cursor += 1
2328
+ else
2329
+ suggestion = history_ghost_for(buffer)
2330
+ if suggestion
2331
+ buffer = suggestion
2332
+ cursor = buffer.length
2333
+ end
2334
+ end
2335
+ when "D" # Left
2336
+ cursor -= 1 if cursor > 0
2337
+ when "H" # Home
2338
+ cursor = 0
2339
+ when "F" # End
2340
+ cursor = buffer.length
2341
+ end
2342
+ end
2343
+ last_tab_prefix = nil
2344
+
2345
+ else
2346
+ if ch.ord >= 32 && ch.ord != 127
2347
+ buffer.insert(cursor, ch)
2348
+ cursor += 1
2349
+ hist_index = HISTORY.length
2350
+ last_tab_prefix = nil
2351
+ end
2352
+ end
2353
+
2354
+ render_line(prompt_str, buffer, cursor) if status == :ok
2355
+ end
2356
+ end
2357
+
2358
+ [status, buffer]
2359
+ end
2360
+
2361
+ # ---------------- Welcome ----------------
2362
+ def print_welcome
2363
+ puts color("Welcome to srsh #{SRSH_VERSION}", t(:ui_hdr))
2364
+ puts ui("Time:", :ui_key) + " " + color(current_time, t(:ui_val))
2365
+ puts cpu_info
2366
+ puts ram_info
2367
+ puts storage_info
2368
+ puts dynamic_quote
2369
+ puts
2370
+ puts color("Coded by https://github.com/RobertFlexx", t(:dim))
2371
+ puts
2372
+ end
2373
+
2374
+ # ---------------- Startup: source rc + plugins ----------------
2375
+ begin
2376
+ rsh_run_script(RC_FILE, []) if File.exist?(RC_FILE)
2377
+ rescue => e
2378
+ STDERR.puts color("srshrc: #{e.class}: #{e.message}", t(:err))
2379
+ end
2380
+
2381
+ load_plugins!
2382
+
2383
+ # ---------------- Script vs interactive entry ----------------
2384
+ if ARGV[0]
2385
+ script_path = ARGV.shift
2386
+ begin
2387
+ rsh_run_script(script_path, ARGV)
2388
+ rescue => e
2389
+ STDERR.puts color("rsh script error: #{e.class}: #{e.message}", t(:err))
2390
+ end
2391
+ exit 0
2392
+ end
2393
+
2394
+ print_welcome
2395
+
2396
+ # ---------------- Main Loop ----------------
2397
+ hostname = Socket.gethostname
2398
+
2399
+ loop do
2400
+ print "\033]0;srsh-#{SRSH_VERSION}\007"
2401
+ prompt_str = prompt(hostname)
2402
+
2403
+ status, input = read_line_with_ghost(prompt_str)
2404
+
2405
+ break if status == :eof
2406
+ next if status == :interrupt
2407
+
2408
+ next if input.nil?
2409
+ input = input.strip
2410
+ next if input.empty?
2411
+
2412
+ HISTORY << input
2413
+ HISTORY.shift while HISTORY.length > HISTORY_MAX
2414
+
2415
+ run_input_line(input)
2416
+ end