rash-command-shell 0.1.3 → 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 925fc43795fdb6dedccf7680890bad76aa0f4e2294bf4db6eef10bfb67e6cdc4
4
- data.tar.gz: 4f57d6387ecfdf2261e491edce9c8f38b2bccc3de23eb16096df0d8312acb444
3
+ metadata.gz: 45f54bfa6b60f6a2a35a32d087ad1a722fb88f36fcd46933ae7145ee9dfc944d
4
+ data.tar.gz: 4dd605ed6fcbfb8a5fbacbd4ff11075933e3e6829785f7605442822f4e5fd508
5
5
  SHA512:
6
- metadata.gz: d2f3aae4dbd89d00963b235a28f6901fdfc7f4aa4dbe1ec592656f1c9acddc48dfd874a77395fd1a4d2e81e2c8c08f1c700f76ae76049d265f148f5985cb294d
7
- data.tar.gz: 69a3a6d0623f316ba75c8a14a3eefde9f2e81214656b9f86836ade185dc83f300be0b97d5df90f383cd106ee71a000ce20ec02bbe2de63aa2d8ceb97cb06560b
6
+ metadata.gz: 55dde4dead963ce469de63b5c7490224d3d29ca406aafb3ef1d1fd0c2813d3433ba258ed433629fc7983cbe9a399560cf7dee912df871e86cf8c491f3ad83a3b
7
+ data.tar.gz: 5fe62b4dcc77acb7774f8b06360d4a4768b6b7bd63633886c40fbdbc7e0af41bd4240cbb1c867912ee11eec287e61f19f468ab9b354ac842346e5b46f6a9caea
data/bin/rash CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  if ARGV.empty?
4
4
  exec("irb", "-r", "rash", *ARGV)
5
+ elsif ARGV[0] =~ /(-)?-v(ersion)?/
6
+ puts "Rash (c) 2020 Kellen Watt"
7
+ puts "Version 0.4.0" # I may forget to update this
5
8
  elsif File.exists?(ARGV[0]) && !File.directory?(ARGV[0])
6
9
  require "rash"
7
10
  file = ARGV.shift
@@ -1,7 +1,7 @@
1
1
  class Environment
2
2
 
3
3
  attr_reader :aliasing_disabled
4
- attr_reader :superuser_mode
4
+ attr_reader :umask
5
5
 
6
6
  def initialize
7
7
  common_init
@@ -14,7 +14,21 @@ class Environment
14
14
  ENV["OLDPWD"] = old.to_s
15
15
  Dir.pwd
16
16
  end
17
-
17
+
18
+ # Note that this works regardless of which version of chdir is used.
19
+ def push_dir(dir = nil)
20
+ @directory_stack.push(Dir.pwd)
21
+ self.chdir(dir)
22
+ end
23
+
24
+ def pop_dir
25
+ self.chdir(@directory_stack.pop) if @directory_stack.size > 0
26
+ end
27
+
28
+ def dirs
29
+ @directory_stack
30
+ end
31
+
18
32
  def add_path(path)
19
33
  ENV["PATH"] += File::PATH_SEPARATOR + (path.respond_to?(:path) ? path.path : path.to_s)
20
34
  end
@@ -28,6 +42,11 @@ class Environment
28
42
  super
29
43
  end
30
44
  end
45
+
46
+ def umask=(mask)
47
+ File.umask(mask)
48
+ @umask = mask
49
+ end
31
50
 
32
51
  def as_superuser(&block)
33
52
  @superuser_mode = true
@@ -37,27 +56,72 @@ class Environment
37
56
  @superuser_mode = false
38
57
  end
39
58
  end
59
+
60
+ def with_limits(limits, &block)
61
+ if block_given?
62
+ pid = fork do
63
+ limits.each {|resource, limit| Process.setrlimit(resource, *limit)}
64
+ block.call
65
+ exit!(true)
66
+ end
67
+ Process.wait(pid)
68
+ else
69
+ limits.each {|resource, limit| Process.setrlimit(resource, *limit)}
70
+ end
71
+ end
40
72
 
73
+ def dispatch(m, *args)
74
+ if @in_pipeline
75
+ add_pipeline(m, *args)
76
+ else
77
+ system_command(m, *args)
78
+ end
79
+ end
80
+
41
81
  private
42
82
 
43
83
  def common_init
44
84
  @working_directory = Dir.pwd
85
+ @umask = File.umask
45
86
 
46
87
  @aliases = {}
47
88
  @aliasing_disabled = false
48
89
  @active_jobs = []
49
90
 
91
+ @active_pipelines = []
92
+
93
+ @directory_stack = []
94
+
50
95
  @prompt = {
51
96
  AUTO_INDENT: true,
52
97
  RETURN: ""
53
98
  }
54
99
  ENV["RASHDIR"] = File.dirname(__FILE__)
55
100
  end
101
+
102
+ def resolve_command(m, *args, literal: false)
103
+ (literal ? [m.to_s] : resolve_alias(m)) + args.flatten.map{|a| a.to_s}
104
+ end
105
+
106
+ def system_command(m, *args, except: false, literal: false, out: nil, input: nil, err: nil)
107
+ command = resolve_command(m, *args, literal: literal)
108
+ command.unshift("sudo") if @superuser_mode
109
+ opts = {out: out || $stdout,
110
+ err: err || $stderr,
111
+ in: input || $stdin,
112
+ exception: except || @superuser_mode,
113
+ umask: @umask}
114
+
115
+ system(*command, opts)
116
+ end
117
+
56
118
  end
57
119
 
58
120
  require_relative "rash/redirection"
59
121
  require_relative "rash/aliasing"
60
122
  require_relative "rash/jobcontrol"
123
+ require_relative "rash/pipeline"
124
+ require_relative "rash/capturing"
61
125
 
62
126
  $env = Environment.new
63
127
 
@@ -73,6 +137,17 @@ def cd(dir = nil)
73
137
  $env.chdir(d)
74
138
  end
75
139
 
140
+ def pushd(dir = nil)
141
+ case dir
142
+ when File, Dir
143
+ dir = dir.path if File.directory(dir.path)
144
+ end
145
+ $env.push_dir(dir)
146
+ end
147
+
148
+ def popd
149
+ $env.pop_dir
150
+ end
76
151
 
77
152
  def run(file, *args)
78
153
  filename = file.to_s
@@ -80,13 +155,15 @@ def run(file, *args)
80
155
  unless File.executable?(exe)
81
156
  raise SystemCallError.new("No such executable file - #{exe}", Errno::ENOENT::Errno)
82
157
  end
83
- system(exe, *args.flatten.map{|a| a.to_s}, {out: $stdout, err: $stderr, in: $stdin})
158
+ $env.dispatch(exe, *args, literal: true)
84
159
  end
85
160
 
86
161
  alias cmd __send__
87
162
 
88
163
  # Defines `bash` psuedo-compatibility. Filesystem effects happen like normal
89
164
  # and environmental variable changes are copied
165
+ #
166
+ # This is an artifact of an old design and is deprecated until further notice.
90
167
  def sourcesh(file)
91
168
  bash_env = lambda do |cmd = nil|
92
169
  tmpenv = `#{cmd + ';' if cmd} printenv`
@@ -101,7 +178,7 @@ end
101
178
 
102
179
 
103
180
  def which(command)
104
- cmd = File.expand_path(command)
181
+ cmd = File.expand_path(command.to_s)
105
182
  return cmd if File.executable?(cmd) && !File.directory?(cmd)
106
183
 
107
184
  exts = ENV['PATHEXT'] ? ENV['PATHEXT'].split(';') : ['']
@@ -121,11 +198,7 @@ end
121
198
  def self.method_missing(m, *args, &block)
122
199
  exe = which(m.to_s)
123
200
  if exe || ($env.alias?(m) && !$env.aliasing_disabled)
124
- if $env.superuser_mode
125
- system("sudo", *$env.resolve_alias(m), *args.flatten.map{|a| a.to_s}, {out: $stdout, err: $stderr, in: $stdin, exception: true})
126
- else
127
- system(*$env.resolve_alias(m), *args.flatten.map{|a| a.to_s}, {out: $stdout, err: $stderr, in: $stdin})
128
- end
201
+ $env.dispatch(m, *args)
129
202
  else
130
203
  super
131
204
  end
@@ -7,26 +7,12 @@ class Environment
7
7
  @aliases.delete(func.to_sym)
8
8
  end
9
9
 
10
- # recursive aliases not currently possible. In the works
11
- def resolve_alias(f)
12
- result = [f.to_s]
13
- aliases = @aliases.dup
14
- found_alias = true
15
- while found_alias
16
- found_alias = false
17
- if aliases.has_key?(result[0].to_sym)
18
- found_alias = true
19
- match = result[0].to_sym
20
- result[0] = aliases[match]
21
- aliases.delete(match)
22
- result.flatten!
23
- end
24
- end
25
- result
26
- end
27
-
28
10
  def alias?(f)
29
- @aliases.has_key?(f.to_sym)
11
+ @aliases.key?(f.to_sym)
12
+ end
13
+
14
+ def aliases
15
+ @aliases.dup
30
16
  end
31
17
 
32
18
  def without_aliasing
@@ -52,4 +38,25 @@ class Environment
52
38
  end
53
39
  end
54
40
  end
41
+
42
+ private
43
+
44
+ # Unless given a compelling reason, this doesn't need to be public. For most
45
+ # purposes, some combination of `alias?` and `aliases` should be sufficient.
46
+ def resolve_alias(f)
47
+ result = [f.to_s]
48
+ aliases = @aliases.dup
49
+ found_alias = true
50
+ while found_alias
51
+ found_alias = false
52
+ if aliases.has_key?(result[0].to_sym)
53
+ found_alias = true
54
+ match = result[0].to_sym
55
+ result[0] = aliases[match]
56
+ aliases.delete(match)
57
+ result.flatten!
58
+ end
59
+ end
60
+ result
61
+ end
55
62
  end
@@ -0,0 +1,45 @@
1
+ class Environment
2
+ def capture_block(&block)
3
+ raise ArgumentError.new("no block provided") unless block_given?
4
+ result = nil
5
+ old_pipeline = @in_pipeline
6
+ begin
7
+ reader, writer = IO.pipe
8
+ self.stdout = writer
9
+ @in_pipeline = false
10
+ block.call
11
+ ensure
12
+ @in_pipeline = old_pipeline
13
+ reset_stdout
14
+ writer.close
15
+ result = reader.read
16
+ reader.close
17
+ end
18
+ result
19
+ end
20
+
21
+ def capture_command(m, *args)
22
+ raise NameError.new("no such command", m) unless which(m) || ($env.alias?(m) && !$env.aliasing_disabled)
23
+ result = nil
24
+ begin
25
+ reader, writer = IO.pipe
26
+ system_command(m, *args, out: writer)
27
+ ensure
28
+ writer.close
29
+ result = reader.read
30
+ reader.close
31
+ end
32
+ result
33
+ end
34
+ end
35
+
36
+ # This explicitly doesn't support pipelining, as the output is ripped out of sequence.
37
+ def capture(*cmd, &block)
38
+ if block_given?
39
+ $env.capture_block(&block)
40
+ elsif cmd.size > 0 && (which(cmd[0]) || ($env.alias?(m) && !$env.aliasing_disabled))
41
+ $env.capture_command(cmd[0].to_s, *cmd[1..])
42
+ else
43
+ raise ArgumentError.new("nothing to capture from")
44
+ end
45
+ end
@@ -2,6 +2,7 @@ class Environment
2
2
 
3
3
  RASH_LOCAL_FILE = ".rashrc.local"
4
4
 
5
+ # Has to be a better way of adding extensions and dynamically loading them
5
6
  def initialize
6
7
  common_init
7
8
  @working_directory = Directory.root("/")
@@ -15,26 +16,30 @@ class Environment
15
16
  Dir.pwd
16
17
  end
17
18
 
18
- def local_def(name, &block)
19
- @working_directory.add_local_method(name.to_sym, &block)
19
+ def local_def(name, locked: false, &block)
20
+ @working_directory.add_local_method(name, &block)
21
+ @working_directory.lock_method(name) if locked
22
+ name.to_sym
20
23
  end
21
24
 
22
25
  def local_undef(name)
23
- @working_directory.clear_local_method(name.to_sym)
26
+ @working_directory.clear_local_method(name)
24
27
  end
25
28
 
26
29
  def local_method?(name)
27
- @working_directory.local_methods.key?(name.to_sym)
30
+ @working_directory.local_method?(name)
31
+ end
32
+
33
+ def local_methods
34
+ @working_directory.local_methods
28
35
  end
29
36
 
30
37
  def local_call(name, *args, &block)
31
- @working_directory.local_methods[name.to_sym].call(*args, &block)
38
+ @working_directory.local_method(name).call(*args, &block)
32
39
  end
33
40
 
34
41
  private
35
42
 
36
- LOCAL_FUNCTIONS_ENABLED = true
37
-
38
43
  # from and to are strings
39
44
  def traverse_filetree(from, to)
40
45
  abs_from = File.expand_path(from)
@@ -66,7 +71,6 @@ class Environment
66
71
  end
67
72
 
68
73
  class Directory
69
- attr_reader :local_methods
70
74
  attr_reader :parent, :children
71
75
 
72
76
  def self.root(dir)
@@ -77,7 +81,31 @@ class Environment
77
81
  @path = Dir.new(dir)
78
82
  @parent = parent
79
83
  @children = []
80
- @local_methods = parent&.local_methods.dup || {}
84
+ @local_methods = parent&.unlocked_local_methods || {}
85
+ @locked_methods = []
86
+ end
87
+
88
+ def local_method(name)
89
+ @local_methods[name.to_sym]
90
+ end
91
+
92
+ def local_methods
93
+ @local_methods.keys
94
+ end
95
+
96
+ def unlocked_local_methods
97
+ @local_methods.filter{|k, v| !@locked_methods.include?(k)}
98
+ end
99
+
100
+ def lock_method(name)
101
+ n = name.to_sym
102
+ raise NameError.new("#{name} is not a local method", n) unless @local_methods.key?(n)
103
+ @locked_methods << n unless @locked_methods.include?(n)
104
+ n
105
+ end
106
+
107
+ def local_method?(name)
108
+ @local_methods.key?(name.to_sym)
81
109
  end
82
110
 
83
111
  def root?
@@ -104,14 +132,14 @@ class Environment
104
132
 
105
133
  def add_local_method(name, &block)
106
134
  raise ArgumentError.new "no method body provided" unless block_given?
107
- @local_methods[name] = block # if name already exists, its function is overriden
108
- name
135
+ @local_methods[name.to_sym] = block # if name already exists, its function is overriden
136
+ name.to_sym
109
137
  end
110
138
 
111
139
  # might not be useful
112
140
  def clear_local_method(name)
113
- @local_methods.delete(name)
114
- name
141
+ @local_methods.delete(name.to_sym)
142
+ name.to_sym
115
143
  end
116
144
 
117
145
  def to_s
@@ -120,16 +148,13 @@ class Environment
120
148
  end
121
149
  end
122
150
 
151
+ # still could absolutely be more cleaned up, but it works
123
152
  def self.method_missing(m, *args, &block)
124
153
  exe = which(m.to_s)
125
154
  if $env.local_method?(m)
126
155
  $env.local_call(m, *args, &block)
127
156
  elsif exe || ($env.alias?(m) && !$env.aliasing_disabled)
128
- if $env.superuser_mode
129
- system("sudo", *$env.resolve_alias(m), *args.flatten.map{|a| a.to_s}, {out: $stdout, err: $stderr, in: $stdin})
130
- else
131
- system(*$env.resolve_alias(m), *args.flatten.map{|a| a.to_s}, {out: $stdout, err: $stderr, in: $stdin})
132
- end
157
+ $env.dispatch(m, *args)
133
158
  else
134
159
  super
135
160
  end
@@ -1,6 +1,6 @@
1
1
  class Environment
2
2
  def jobs
3
- @active_jobs.keep_if {|pid| Process.kill(0, pid) rescue false}
3
+ @active_jobs.keep_if{|pid| Process.kill(0, pid) rescue false}.dup
4
4
  end
5
5
 
6
6
  def async(&block)
@@ -17,5 +17,3 @@ end
17
17
  def as_background(&block)
18
18
  $env.async(&block)
19
19
  end
20
-
21
-
@@ -0,0 +1,198 @@
1
+ class Environment
2
+
3
+ def pipelined?
4
+ @in_pipeline
5
+ end
6
+
7
+ def synced_pipeline?
8
+ @in_pipeline && @synchronous_pipeline
9
+ end
10
+
11
+ def make_pipeline(&block)
12
+ raise IOError.new("pipelining already enabled") if @in_pipeline
13
+ start_pipeline
14
+ begin
15
+ block.call
16
+ ensure
17
+ end_pipeline
18
+ end
19
+ nil
20
+ end
21
+
22
+ def make_sync_pipeline(&block)
23
+ raise IOError.new("pipelining already enabled") if @in_pipeline
24
+ start_sync_pipeline
25
+ begin
26
+ block.call
27
+ ensure
28
+ end_sync_pipeline
29
+ end
30
+ nil
31
+ end
32
+
33
+ def as_pipe_command(&block)
34
+ raise IOError.new("pipelining not enabled") unless @in_pipeline
35
+ return as_sync_pipe_command(&block) if @synchronous_pipeline
36
+
37
+ input = (@active_pipelines.empty? ? $stdin : @active_pipelines.last.reader)
38
+ @active_pipelines << Pipeline.new
39
+ output = @active_pipelines.last.writer
40
+ error = ($stderr == $stdout ? output : $stderr)
41
+
42
+ pid = fork do
43
+ @in_pipeline = false
44
+ $stdin = input
45
+ $stdout = output
46
+ $stderr = error
47
+ block.call
48
+ output.close
49
+ exit!(true)
50
+ end
51
+ output.close
52
+
53
+ @active_pipelines.last.link_process(pid)
54
+ nil
55
+ end
56
+
57
+ def as_sync_pipe_command(&block)
58
+ raise IOError.new("pipelining not enabled") unless @in_pipeline
59
+ raise IOError.new("pipeline is not synchronous") unless @synchronous_pipeline
60
+
61
+ @next_pipe.close
62
+ @next_pipe = Pipeline.new # flush the output pipe
63
+ @prev_pipe.writer.close
64
+
65
+ input = (@first_sync_command ? $stdin : @prev_pipe.reader)
66
+ @first_sync_command = false
67
+ output = @next_pipe.writer
68
+ error = ($stderr == $stdout ? @next_pipe.writer : $stdin)
69
+
70
+ pid = fork do
71
+ @in_pipeline = false
72
+ @synchronous_pipeline = false
73
+ $stdin = input
74
+ $stdout = output
75
+ $stderr = error
76
+ block.call
77
+ exit!(true)
78
+ end
79
+
80
+ Process.wait(pid)
81
+ @prev_pipe, @next_pipe = @next_pipe, @prev_pipe
82
+ nil
83
+ end
84
+
85
+ private
86
+
87
+ def start_pipeline
88
+ @in_pipeline = true
89
+ end
90
+
91
+ def start_sync_pipeline
92
+ @in_pipeline = true
93
+ @synchronous_pipeline = true
94
+ @first_sync_command = true
95
+ @prev_pipe = Pipeline.new
96
+ @next_pipe = Pipeline.new
97
+ end
98
+
99
+ def end_pipeline
100
+ raise IOError.new("pipelining not enabled") unless @in_pipeline
101
+ @in_pipeline = false
102
+ if @active_pipelines.size > 0
103
+ begin
104
+ Process.wait(@active_pipelines.last.pid)
105
+ @active_pipelines.last.writer.close # probably redundant, but leaving it for now
106
+ IO.copy_stream(@active_pipelines.last.reader, $stdout)
107
+ @active_pipelines.pop.close
108
+ @active_pipelines.reverse_each {|pipe| pipe.terminate}
109
+ ensure
110
+ @active_pipelines.clear
111
+ end
112
+ end
113
+ end
114
+
115
+ def end_sync_pipeline
116
+ raise IOError.new("pipelining not enabled") unless @in_pipeline
117
+ raise IOError.new("pipeline is not synchronous") unless @synchronous_pipeline
118
+ @next_pipe.close
119
+ @prev_pipe.writer.close
120
+ IO.copy_stream(@prev_pipe.reader, $stdout)
121
+ @prev_pipe.close
122
+
123
+ @next_pipe = @prev_pipe = @first_sync_command = nil
124
+ @synchronous_pipeline = @in_pipeline = false
125
+ end
126
+
127
+ # special method to be referenced from Environment#dispatch. Do not use directly
128
+ def add_pipeline(m, *args)
129
+ raise IOError.new("pipelining not enabled") unless @in_pipeline
130
+ return add_sync_pipeline(m, *args) if @synchronous_pipeline
131
+
132
+ input = (@active_pipelines.empty? ? $stdin : @active_pipelines.last.reader)
133
+ @active_pipelines << Pipeline.new
134
+ output = @active_pipelines.last.writer
135
+ error = ($stderr == $stdout ? output : $stderr)
136
+ pid = fork do # might not be necessary, spawn might cover it. Not risking it before testing
137
+ system_command(m, *args, out: output, input: input, err: error, except: true)
138
+ output.close
139
+ exit!(true)
140
+ end
141
+ output.close
142
+ @active_pipelines.last.link_process(pid)
143
+ end
144
+
145
+ def add_sync_pipeline(m, *args)
146
+ raise IOError.new("pipelining not enabled") unless @in_pipeline
147
+ raise IOError.new("pipeline is not synchronous") unless @synchronous_pipeline
148
+
149
+ # Ensure pipe is empty for writing
150
+ @next_pipe.close
151
+ @next_pipe = Pipeline.new
152
+ @prev_pipe.writer.close
153
+
154
+ input = (@first_sync_command ? $stdin : @prev_pipe.reader)
155
+ @first_sync_command = false
156
+ error = ($stderr == $stdout ? @next_pipe.writer : $stdin)
157
+ system_command(m, *args, out: @next_pipe.writer, input: input, err: error, except: true)
158
+ @prev_pipe, @next_pipe = @next_pipe, @prev_pipe
159
+ nil
160
+ end
161
+
162
+ class Pipeline
163
+ attr_reader :writer, :reader, :pid
164
+
165
+ def initialize
166
+ @reader, @writer = IO.pipe
167
+ end
168
+
169
+ def link_process(pid)
170
+ @pid ||= pid
171
+ self
172
+ end
173
+
174
+ def close
175
+ @writer.close
176
+ @reader.close
177
+ end
178
+
179
+ def terminate
180
+ self.close
181
+ Process.kill(:TERM, @pid)
182
+ Process.wait(@pid)
183
+ end
184
+
185
+ def to_s
186
+ @pid
187
+ end
188
+ end
189
+ end
190
+
191
+
192
+ def in_pipeline(async: true, &block)
193
+ if async
194
+ $env.make_pipeline(&block)
195
+ else
196
+ $env.make_sync_pipeline(&block)
197
+ end
198
+ end
@@ -1,7 +1,5 @@
1
1
  class Environment
2
2
 
3
- DEFAULT_IO = {in: STDIN, out: STDOUT, err: STDERR}
4
-
5
3
  def reset_io
6
4
  reset_stdout
7
5
  reset_stderr
@@ -78,6 +76,10 @@ class Environment
78
76
  def standard_stream?(f)
79
77
  DEFAULT_IO.values.include?(f)
80
78
  end
79
+
80
+ private
81
+
82
+ DEFAULT_IO = {in: STDIN, out: STDOUT, err: STDERR}
81
83
  end
82
84
 
83
85
  # If you want to append, you need to get the file object yourself.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rash-command-shell
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kellen Watt
@@ -24,7 +24,7 @@ dependencies:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
26
  version: '1.2'
27
- description: A Ruby-based shell
27
+ description: A Ruby-based command shell
28
28
  email:
29
29
  executables:
30
30
  - rash
@@ -34,8 +34,10 @@ files:
34
34
  - bin/rash
35
35
  - lib/rash.rb
36
36
  - lib/rash/aliasing.rb
37
+ - lib/rash/capturing.rb
37
38
  - lib/rash/ext/filesystem.rb
38
39
  - lib/rash/jobcontrol.rb
40
+ - lib/rash/pipeline.rb
39
41
  - lib/rash/prompt/irb.rb
40
42
  - lib/rash/redirection.rb
41
43
  homepage: https://github.com/KellenWatt/rash