rtfm-filemanager 8.2.6 → 8.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/bin/rtfm +190 -44
  4. metadata +3 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7ba9bb8fbd17a51b1016eef29ceb5718dba8f6819bb24c6aee7b78aa7d8e7b83
4
- data.tar.gz: 3ce4361180ee23a2e446d1fb0d26f4d7d144517cef841bee9444e871f922c4e8
3
+ metadata.gz: 7d97437ca84138aaa2ad3fc03b54c2d607f39cda4e1d6fead69bef9d83278a9d
4
+ data.tar.gz: feb00468052819fdbb1c7c109406db875eba2be13c98273f0d16cbd90fb968ce
5
5
  SHA512:
6
- metadata.gz: 9303820f8f84adfb87b589925d16fb9b02102fd1c7df9a4a845b04b5854558e0910231b3d286488aa0d905252845794a5030ac7c72ff25b5ce3637af418db68f
7
- data.tar.gz: 82dbd79b2fc59c33001f3436a53bd9ad0f3ddcfafc112e8bc618788e47e6f88f2b40646d73189cedc3f7f0535302577b63cd959a4ed60731f8df5ab79dcf30e9
6
+ metadata.gz: 59981f04215c73a44f4821d1dbf3dddb74db5590521e746567c0970c157c96aca84b48c9c2ee3afa20037537734937e7b59c56b9a23c2cee8d947e67c0cefa2f
7
+ data.tar.gz: ac25468b6d11effefb11b5debd5e006c9d11aa689e623f638d5ee35a06e798b93343481b8b8b4f7f3950ae7acf046f117cdaecc37de43e050abc2b2a39100de2
data/CHANGELOG.md CHANGED
@@ -5,6 +5,27 @@ All notable changes to RTFM will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [8.6.0] - 2026-05-19
9
+
10
+ ### Added
11
+ - **`Terminal=true` in `.desktop` files honored** - When opening a file, if the resolved `.desktop` file declares `Terminal=true`, RTFM treats the program as interactive even when it isn't in `@interactive`. Newly installed TUI tools now "just work" without editing config. The `@interactive` whitelist still wins for programs whose `.desktop` files lie about being terminal apps
12
+
13
+ ### Fixed
14
+ - **LS_COLORS fallback via `dircolors -b`** - When `$LS_COLORS` is unset or empty (cron launches, minimal login envs, some macOS setups), RTFM now seeds it from `dircolors -b` at startup so `ls --color` still emits ANSI. Directory listings stay colored in environments that don't export LS_COLORS
15
+
16
+ ## [8.5.0] - 2026-05-19
17
+
18
+ ### Added
19
+ - **Crash log** - Unhandled exceptions now append to `~/.rtfm/crash.log` with timestamp, version, Ruby/platform info, cwd, exception class/message, and full backtrace. The `at_exit` handler skips `SystemExit`/`Interrupt` so normal quits stay silent. Makes bug reports actionable instead of guesswork from terminal scrollback
20
+ - **Persistent per-directory cursor** - The in-memory `@directory` hash that tracks selected index per directory is now saved to `~/.rtfm/conf` on quit (capped at 200 entries, prunes deleted dirs). Re-enter a directory in a later session and the cursor lands where you left it
21
+ - **`--fresh` CLI flag** - Launch with `rtfm --fresh` to ignore the saved cursor map for one session (useful when the saved positions are stale or you want a clean slate)
22
+ - **File-path argument** - `rtfm /etc/hosts` now cd's into `/etc` and places the cursor on `hosts`. Previously only directory arguments worked
23
+ - **Claude (Anthropic) support for AI features** - Set `@aimodel = "claude-sonnet-4-6"` (or any `claude-*` model) in `~/.rtfm/conf` and both `I` (file description) and `Ctrl-a` (chat) route to the Anthropic API via curl. OpenAI continues to work for non-`claude-*` model names. Same `@ai` config field holds either key
24
+ - **Non-blocking `:` commands** - Non-interactive shell commands now run in a background thread. Launching GUI apps (`xdg-open`, `firefox`, `evince`, etc.) no longer freezes RTFM until they exit. Output drops into the right pane when the command completes. The bottom pane shows running/done status
25
+
26
+ ### Fixed
27
+ - **Bootsnap optional** - Missing `bootsnap` gem no longer crashes RTFM on startup. It was always intended as a startup-speed optimization; now `require 'bootsnap/setup'` is wrapped in a `LoadError` rescue and RTFM continues without it
28
+
8
29
  ## [8.2.6] - 2026-04-21
9
30
 
10
31
  ### Added
data/bin/rtfm CHANGED
@@ -18,21 +18,58 @@
18
18
  # get a great understanding of the code itself by simply sending
19
19
  # or pasting this whole file into you favorite AI for coding with
20
20
  # a prompt like this: "Help me understand every part of this code".
21
- @version = '8.2.6' # Add `hyper` to the default @interactive TUI-app list
21
+ @version = '8.6.0' # LS_COLORS fallback (dircolors -b) + Terminal=true in .desktop honored
22
22
 
23
23
  # SAVE & STORE TERMINAL {{{1
24
24
  ORIG_STTY = `stty -g`.chomp
25
25
 
26
26
  at_exit do
27
+ # Log unhandled exceptions to ~/.rtfm/crash.log so users can file bug reports
28
+ # without losing the backtrace to terminal scrollback.
29
+ if $! && !$!.is_a?(SystemExit) && !$!.is_a?(Interrupt)
30
+ begin
31
+ log = File.join(Dir.home, '.rtfm', 'crash.log')
32
+ require 'fileutils'
33
+ FileUtils.mkdir_p(File.dirname(log))
34
+ File.open(log, 'a') do |f|
35
+ f.puts '=' * 60
36
+ f.puts "RTFM v#{@version} crash at #{Time.now}"
37
+ f.puts "Ruby #{RUBY_VERSION} on #{RUBY_PLATFORM}"
38
+ f.puts "PWD: #{Dir.pwd rescue 'unknown'}"
39
+ f.puts "#{$!.class}: #{$!.message}"
40
+ f.puts($!.backtrace.join("\n")) if $!.backtrace
41
+ end
42
+ rescue StandardError
43
+ # never crash inside the crash handler
44
+ end
45
+ end
27
46
  system("stty #{ORIG_STTY} < /dev/tty") rescue nil
28
47
  end
29
48
 
30
49
  # BOOTSNAP {{{1
50
+ # Optional startup speedup. If the gem isn't installed, RTFM just starts
51
+ # a bit slower instead of refusing to run.
31
52
  cache_dir = ENV.fetch('BOOTSNAP_CACHE_DIR', File.join(Dir.home, '.rtfm', 'bootsnap-cache'))
32
53
  ENV['BOOTSNAP_CACHE_DIR'] = cache_dir
33
54
  require 'fileutils'
34
55
  FileUtils.mkdir_p(cache_dir)
35
- require 'bootsnap/setup' # Speed up subsequent requires
56
+ begin
57
+ require 'bootsnap/setup'
58
+ rescue LoadError
59
+ # bootsnap not installed — proceed without compile cache
60
+ end
61
+
62
+ # LS_COLORS FALLBACK {{{1
63
+ # RTFM relies on `ls --color` ANSI output for left/right pane colors, and
64
+ # `ls` itself reads $LS_COLORS. If the user's shell never exports it (cron
65
+ # launches, minimal login environments, some macOS setups) the listing
66
+ # comes back colorless. Seed it from `dircolors -b` when available.
67
+ if (ENV['LS_COLORS'].nil? || ENV['LS_COLORS'].empty?) && !`which dircolors 2>/dev/null`.strip.empty?
68
+ dc = `dircolors -b 2>/dev/null`
69
+ if (m = dc.match(/LS_COLORS='([^']*)'/))
70
+ ENV['LS_COLORS'] = m[1]
71
+ end
72
+ end
36
73
 
37
74
  # ENCODING {{{1
38
75
  # encoding: utf-8
@@ -616,6 +653,13 @@ $stdin.set_encoding(Encoding::UTF_8)
616
653
  @file_op_complete = false # Flag when operation finishes
617
654
  @file_op_result = nil # Result message when operation finishes
618
655
 
656
+ ## Async shell command (`:` mode, non-interactive branch)
657
+ @shell_cmd_thread = nil # Current background shell-command thread
658
+ @shell_cmd_running = false # True while a background `:` cmd is running
659
+ @shell_cmd_label = nil # Command string for status display
660
+ @shell_cmd_complete = false # Set by worker when output is ready
661
+ @shell_cmd_output = nil # Captured stdout+stderr
662
+
619
663
  ## Recently accessed files/directories
620
664
  @recent_files = [] # Last 50 accessed files
621
665
  @recent_dirs = [] # Last 20 accessed directories
@@ -860,11 +904,33 @@ if (pick_arg = ARGV.find { |a| a.start_with?('--pick=') })
860
904
  ARGV.delete(pick_arg)
861
905
  end
862
906
 
907
+ # --fresh: ignore any saved per-directory cursor positions for this session.
908
+ # @directory is still tracked in-memory, but startup restoration is skipped.
909
+ @fresh = false
910
+ if ARGV.delete('--fresh')
911
+ @fresh = true
912
+ @directory = {}
913
+ end
914
+
863
915
  # Handle start dir {{{2
864
- Dir.chdir(ARGV.shift) if ARGV[0] && File.directory?(ARGV[0])
916
+ # Accept either a directory or a file path. With a file path, cd into its
917
+ # parent and queue the basename for cursor restoration after load_dir runs.
918
+ @startup_select = nil
919
+ if (arg = ARGV[0]) && !arg.start_with?('-')
920
+ if File.directory?(arg)
921
+ Dir.chdir(ARGV.shift)
922
+ elsif File.exist?(arg)
923
+ @startup_select = File.basename(arg)
924
+ Dir.chdir(File.dirname(File.expand_path(arg)))
925
+ ARGV.shift
926
+ end
927
+ end
865
928
 
866
929
  # Initialize first tab {{{2
867
930
  create_tab(Dir.pwd, "Main")
931
+ # Seed the first tab's per-tab directory_memory with the persisted @directory
932
+ # so switching tabs and back doesn't lose the saved cursor map.
933
+ @tabs[0][:directory_memory] = @directory.dup if @tabs[0]
868
934
 
869
935
  # OPENAI SETUP {{{1
870
936
  def chat_history # {{{2
@@ -880,6 +946,33 @@ def openai_client # {{{2
880
946
  @openai_client ||= OpenAI::Client.new(access_token: @ai)
881
947
  end
882
948
 
949
+ # AI dispatch: routes to Anthropic when @aimodel starts with 'claude-',
950
+ # otherwise OpenAI. Returns the assistant message text, or nil on failure.
951
+ # Anthropic uses curl (no extra gem dependency); OpenAI uses ruby-openai.
952
+ def ai_request(messages, max_tokens = 600) # {{{2
953
+ if @aimodel.to_s.start_with?('claude-')
954
+ require 'json'
955
+ # Anthropic expects "system" as a top-level field, not a message role.
956
+ sys = messages.find { |m| (m[:role] || m['role']).to_s == 'system' }
957
+ msgs = messages.reject { |m| (m[:role] || m['role']).to_s == 'system' }
958
+ body = { model: @aimodel, max_tokens: max_tokens, messages: msgs }
959
+ body[:system] = sys[:content] || sys['content'] if sys
960
+ out = `curl -sS -X POST https://api.anthropic.com/v1/messages \
961
+ -H 'content-type: application/json' \
962
+ -H 'anthropic-version: 2023-06-01' \
963
+ -H #{Shellwords.escape("x-api-key: #{@ai}")} \
964
+ -d #{Shellwords.escape(body.to_json)} 2>/dev/null`
965
+ return nil if out.empty?
966
+ json = JSON.parse(out) rescue nil
967
+ json && json.dig('content', 0, 'text')
968
+ else
969
+ resp = openai_client.chat(
970
+ parameters: { model: @aimodel, messages: messages, max_tokens: max_tokens }
971
+ ) rescue nil
972
+ resp&.dig('choices', 0, 'message', 'content')
973
+ end
974
+ end
975
+
883
976
  # SET UP VIEWER SYSTEM {{{1
884
977
  # rubocop:disable Style/StringLiterals
885
978
  preview_specs = {
@@ -4804,16 +4897,19 @@ end
4804
4897
 
4805
4898
  def openai_description # {{{3
4806
4899
  clear_image
4807
- begin
4808
- require 'ruby/openai'
4809
- rescue LoadError
4810
- @pB.say('To enable AI-descriptions: `gem install ruby-openai` and set @ai in ~/.rtfm/conf')
4811
- return
4812
- end
4813
4900
  unless @ai && !@ai.empty?
4814
- @pB.say("Set your API key in ~/.rtfm/conf: @ai = 'your-secret-openai-key'")
4901
+ @pB.say("Set your API key in ~/.rtfm/conf: @ai = 'your-api-key'")
4815
4902
  return
4816
4903
  end
4904
+ # ruby-openai is only needed for OpenAI models; Anthropic uses curl.
4905
+ unless @aimodel.to_s.start_with?('claude-')
4906
+ begin
4907
+ require 'ruby/openai'
4908
+ rescue LoadError
4909
+ @pB.say('For OpenAI models: `gem install ruby-openai`. For Claude, set @aimodel to a claude-* model.')
4910
+ return
4911
+ end
4912
+ end
4817
4913
  # Context
4818
4914
  path = File.join(Dir.pwd, @selected.to_s)
4819
4915
  is_dir = File.directory?(path)
@@ -4827,27 +4923,26 @@ def openai_description # {{{3
4827
4923
  parts << "Git-Aware Diff Explanation: summarize the most recent `git diff` touching #{path}, explaining what changed." if Dir.exist?(File.join(Dir.pwd, '.git'))
4828
4924
  parts << "Existing preview text: #{preview}" unless preview.empty?
4829
4925
  prompt = parts.join(' ')
4830
- # Send to OpenAI
4831
- client = OpenAI::Client.new(access_token: @ai)
4832
4926
  @pR.say('Thinking...'.fg(244))
4833
- response = client.chat(
4834
- parameters: {
4835
- model: @aimodel,
4836
- messages: [{ role: 'user', content: prompt }],
4837
- max_tokens: 600
4838
- }
4839
- ) rescue nil
4840
- answer = response&.dig('choices', 0, 'message', 'content') ||
4841
- '⚠️ Error or empty response from OpenAI.'
4927
+ answer = ai_request([{ role: 'user', content: prompt }], 600) ||
4928
+ '⚠️ Error or empty response from AI provider.'
4842
4929
  @pR.say(answer.fg(230))
4843
4930
  end
4844
4931
 
4845
4932
  def chat_mode # {{{3
4846
4933
  clear_image
4847
- unless defined?(OpenAI) && @ai && !@ai.empty?
4848
- @pB.say("To make OpenAI work in RTFM, run `gem install ruby-openai` and add to ~/.rtfm/conf:\n @ai = 'your-secret-openai-key'")
4934
+ unless @ai && !@ai.empty?
4935
+ @pB.say("Set your API key in ~/.rtfm/conf: @ai = 'your-api-key'")
4849
4936
  return
4850
4937
  end
4938
+ unless @aimodel.to_s.start_with?('claude-')
4939
+ begin
4940
+ require 'ruby/openai'
4941
+ rescue LoadError
4942
+ @pB.say('For OpenAI models: `gem install ruby-openai`. For Claude, set @aimodel to a claude-* model.')
4943
+ return
4944
+ end
4945
+ end
4851
4946
 
4852
4947
  @pB.clear; @pB.update = true
4853
4948
  question = @pAI.ask('Chat> ', '').strip
@@ -4855,15 +4950,7 @@ def chat_mode # {{{3
4855
4950
 
4856
4951
  chat_history << { role: 'user', content: question }
4857
4952
  @pR.say('Thinking...'.fg(230))
4858
- reply = openai_client.chat(
4859
- parameters: {
4860
- model: @aimodel,
4861
- messages: chat_history,
4862
- max_tokens: 400
4863
- }
4864
- ) rescue nil
4865
- answer = reply&.dig('choices', 0, 'message', 'content') ||
4866
- '⚠️ API error or empty response'
4953
+ answer = ai_request(chat_history, 400) || '⚠️ API error or empty response'
4867
4954
  chat_history << { role: 'assistant', content: answer }
4868
4955
  @pR.say(answer.fg(230))
4869
4956
  @pB.clear; @pB.update = true
@@ -5385,15 +5472,33 @@ def command_mode # {{{3
5385
5472
  refresh
5386
5473
  render
5387
5474
  else
5388
- # Check if it's a GUI application that should run without timeout
5389
- gui_apps = ['evince', 'xdg-open', 'firefox', 'chrome', 'chromium', 'eog', 'nautilus', 'thunar', 'gedit', 'code', 'subl']
5390
- prog_base = File.basename(prog.to_s)
5391
- is_gui = gui_apps.include?(prog_base)
5392
-
5393
- # Use nil timeout (wait forever) for GUI apps, 10 seconds for others
5394
- shellexec(cmd, timeout: is_gui ? nil : 10)
5395
- @pR.refresh
5396
- @pR.update = false
5475
+ # Run non-interactive commands in a background thread so GUI apps
5476
+ # (xdg-open, firefox, evince, …) and long-running tools don't freeze
5477
+ # the TUI. The main loop polls @shell_cmd_complete and drops output
5478
+ # into the right pane when ready.
5479
+ if @shell_cmd_running
5480
+ @pB.say("A background command is still running: #{@shell_cmd_label}".fg(214))
5481
+ return
5482
+ end
5483
+ @shell_cmd_running = true
5484
+ @shell_cmd_complete = false
5485
+ @shell_cmd_output = nil
5486
+ @shell_cmd_label = cmd
5487
+ cmd_str = cmd.dup
5488
+ @shell_cmd_thread = Thread.new do
5489
+ begin
5490
+ out, err, _status = Open3.capture3('sh', '-c', cmd_str)
5491
+ combined = out.dup
5492
+ combined << "\n" << err.fg(196) unless err.empty?
5493
+ @shell_cmd_output = combined
5494
+ rescue StandardError => e
5495
+ @shell_cmd_output = "Error: #{e.class}: #{e.message}".fg(196)
5496
+ ensure
5497
+ @shell_cmd_complete = true
5498
+ end
5499
+ end
5500
+ @pB.say(" Running: #{cmd}".fg(244))
5501
+ @pB.update = true
5397
5502
  end
5398
5503
  end
5399
5504
 
@@ -6415,11 +6520,18 @@ def get_interactive_program(file_path) # HELPER FOR OPEN_SELECTED TO USE @intera
6415
6520
  if desktop_path
6416
6521
  content = File.read(desktop_path)
6417
6522
  exec_line = content[/^Exec=(.*)$/m, 1]
6523
+ # Honor Terminal=true in the .desktop file: any app declaring
6524
+ # itself a terminal program is treated as interactive even if
6525
+ # the user hasn't manually added it to @interactive. Lets newly
6526
+ # installed TUI tools "just work" without editing config.
6527
+ terminal_true = content =~ /^Terminal\s*=\s*true\b/i
6418
6528
  if exec_line
6419
- # Extract the program name (first word, remove path)
6420
6529
  prog = exec_line.split.first
6421
6530
  prog = File.basename(prog) if prog
6422
- return prog if prog && inter_list.include?(prog)
6531
+ if prog
6532
+ return prog if inter_list.include?(prog)
6533
+ return prog if terminal_true
6534
+ end
6423
6535
  end
6424
6536
  end
6425
6537
  end
@@ -6574,7 +6686,8 @@ def conf_write(all: false) # WRITE TO ~/.rtfm/conf {{{2
6574
6686
  'history' => "@history = #{@pCmd.history.reverse.uniq.reverse.last(40)}",
6575
6687
  'rubyhistory' => "@rubyhistory = #{@pRuby.history.reverse.uniq.reverse.last(40)}",
6576
6688
  'aihistory' => "@aihistory = #{@pAI.history.reverse.uniq.reverse.last(40)}",
6577
- 'sshhistory' => "@sshhistory = #{@pSsh.history.reverse.uniq.reverse.last(40)}"
6689
+ 'sshhistory' => "@sshhistory = #{@pSsh.history.reverse.uniq.reverse.last(40)}",
6690
+ 'directory' => "@directory = #{@directory.select { |d, _| Dir.exist?(d) }.to_a.last(200).to_h}"
6578
6691
  }
6579
6692
  if all
6580
6693
  assignments.merge!(
@@ -7063,6 +7176,13 @@ end
7063
7176
  @pSsh.record = true
7064
7177
  @pSsh.history = @sshhistory
7065
7178
 
7179
+ # Restore startup cursor from saved per-directory positions (unless --fresh).
7180
+ # A file-path argument is applied after the first render in the main loop,
7181
+ # since @files isn't populated until render() runs.
7182
+ if !@fresh && @directory[Dir.pwd]
7183
+ @index = @directory[Dir.pwd]
7184
+ end
7185
+
7066
7186
  # Report plugin errors {{{2
7067
7187
  @pR.say("Plugin load errors:\n" + @plugin_errors.join("\n").fg(196)) if @plugin_errors.any?
7068
7188
 
@@ -7096,6 +7216,23 @@ loop do
7096
7216
  @pB.update = false
7097
7217
  end
7098
7218
 
7219
+ # Check async `:` shell command (non-interactive branch in command_mode)
7220
+ if @shell_cmd_complete
7221
+ @shell_cmd_complete = false
7222
+ @shell_cmd_running = false
7223
+ @shell_cmd_thread = nil
7224
+ if @shell_cmd_output && !@shell_cmd_output.empty?
7225
+ clear_image
7226
+ @pR.say(@shell_cmd_output)
7227
+ end
7228
+ @pB.say(" Done: #{@shell_cmd_label}".fg(156))
7229
+ @shell_cmd_label = nil
7230
+ @shell_cmd_output = nil
7231
+ # Directory may have changed (file created/removed); invalidate cache
7232
+ @dir_cache.delete_if { |key, _| key.start_with?("#{Dir.pwd}:") }
7233
+ @pL.update = @pR.update = @pB.update = true
7234
+ end
7235
+
7099
7236
  # Handle pending terminal resize (debounced from SIGWINCH)
7100
7237
  if @winch_pending && !@external_program_running
7101
7238
  @winch_pending = false
@@ -7115,6 +7252,15 @@ loop do
7115
7252
  rescue Errno::EIO
7116
7253
  # Note: rcurses 4.9.0+ has enhanced error handling, reducing need for this
7117
7254
  end
7255
+ # Apply file-argument cursor selection on first render (when @files is populated)
7256
+ if @startup_select && @files && !@files.empty?
7257
+ ix = @files.index(@startup_select)
7258
+ if ix
7259
+ @index = ix
7260
+ @pL.update = @pR.update = true
7261
+ end
7262
+ @startup_select = nil
7263
+ end
7118
7264
  # read key, but ignore TTY-focus errors
7119
7265
  begin
7120
7266
  getkey
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rtfm-filemanager
3
3
  version: !ruby/object:Gem::Version
4
- version: 8.2.6
4
+ version: 8.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Geir Isene
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-04-21 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: rcurses
@@ -104,7 +103,6 @@ licenses:
104
103
  - Unlicense
105
104
  metadata:
106
105
  source_code_uri: https://github.com/isene/RTFM
107
- post_install_message:
108
106
  rdoc_options: []
109
107
  require_paths:
110
108
  - lib
@@ -119,8 +117,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
119
117
  - !ruby/object:Gem::Version
120
118
  version: '0'
121
119
  requirements: []
122
- rubygems_version: 3.4.20
123
- signing_key:
120
+ rubygems_version: 3.6.7
124
121
  specification_version: 4
125
122
  summary: RTFM - Ruby Terminal File Manager
126
123
  test_files: []