ask-tools-shell 0.3.4 → 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: 6c8152ae1dcc82318421fde8c5a890ada7a6d4f11435a6e642e0724a06bab80e
4
- data.tar.gz: ce7bb3ea38eb7514ae0f4e44e9cd4adfc1ebed5cf413cc9b424b74e17ce183a8
3
+ metadata.gz: d9920ae3e941453ed140a053b18b39f59d336f5dbc20334c6b8a262b7554f956
4
+ data.tar.gz: 3a4ad48154b393b9d69f39d2a6cc4af8fcf4a465a59921017fe7d2c62892d440
5
5
  SHA512:
6
- metadata.gz: 284a6529ddae8a8099ab8d5af6d53fa2e3f53d97780c6ed9b94a26cb8eb6773500c12b1efb9f4087f3f415dc85b91b8724ecad2483ce511ca5c7e863d0dd00c8
7
- data.tar.gz: a1d656f3851f88949869dc9f4aa1591f79c2ae394fa065f4e8767a55af4c004f63f84df06ef88f11bbfec27c5919b7374cc278f8eb97245f93c02799143b8e6e
6
+ metadata.gz: 8ff7cd4230e7e4be6e2ae9b2a2a0410ed994959fedb0da3628caedff322cb84bd531e8bb62cf5d9b669fb12d759c675ef47d2f4deb32738d292bbbf710a193f7
7
+ data.tar.gz: a404e7da5f72e0ca458f04bfee0d72ed56d609e39db59e665f4e3a837b1b229a11879317770e6fa901c6cb5ba6e1813834d7464fbba69d34f2dee4fa64a9fba3
data/CHANGELOG.md CHANGED
@@ -1,3 +1,27 @@
1
+ ## [0.4.0] - 2026-08-05
2
+
3
+ ### Added
4
+ - **`Ask::Tools::Repl`** — evaluate Ruby code in a persistent session (the
5
+ RLM / recursive-language-model pattern). A long-lived plain-ruby kernel
6
+ subprocess keeps state across calls: locals, `require`s, and defined
7
+ methods survive between evaluations, so the model composes capabilities as
8
+ code against a working environment instead of re-bootstrapping each time.
9
+ - Framed newline-delimited JSON protocol over stdin/stdout with
10
+ request/response id matching; concurrent calls to a session serialize.
11
+ - Per-evaluation timeout kills the session (state is lost, kernel
12
+ respawns fresh on next call); idle sessions recycle after
13
+ `Repl.idle_timeout` (default 300s).
14
+ - Named sessions shared process-wide (`session:` param, default
15
+ `"default"`); `reset: true` discards state; `Repl.close_session` /
16
+ `Repl.close_all` manage lifetimes; `at_exit` cleanup.
17
+ - Sessions are isolated subprocesses — a crash in one session can't take
18
+ others down, and a dead session is respawned transparently with one
19
+ retry.
20
+ - Kernel spawn strips bundler env vars (RUBYOPT, GEM_HOME, etc.) so the
21
+ session is plain ruby and sees globally installed gems — consistent
22
+ with the one-shot `Code` tool.
23
+ - Registered `repl` in `Shell::TOOLS` / `Shell.all`.
24
+
1
25
  ## [0.3.4] - 2026-06-25
2
26
 
3
27
  ### Fixed
data/README.md CHANGED
@@ -1,171 +1,59 @@
1
1
  # ask-tools-shell
2
2
 
3
- Shell, filesystem, and code execution tools for AI agents. Part of the ask-rb ecosystem.
3
+ [![Gem Version](https://badge.fury.io/rb/ask-tools-shell.svg)](https://badge.fury.io/rb/ask-tools-shell)
4
4
 
5
- Provides **Bash**, **Read**, **Write**, **Edit**, **Glob**, **Grep**, and **Code** the execution tools every agent needs.
5
+ Shell, filesystem, and code execution tools for AI agents. Ships 8 tools: Bash, Read, Write, Edit, Glob, Grep, Code, and ApplyPatch. Bash and Code execute through ask-sandbox-providers; the rest operate directly on the local filesystem.
6
+
7
+ ## Installation
6
8
 
7
9
  ```ruby
8
10
  gem "ask-tools-shell"
9
11
  ```
10
12
 
11
- ## Dependencies
12
-
13
- - **ask-tools** ~> 0.1 (provides `Ask::Tool` base class and `Ask::Result`)
14
-
15
- ---
16
-
17
13
  ## Quick Start
18
14
 
19
15
  ```ruby
20
16
  require "ask-tools-shell"
21
17
 
22
- # List all available tools
23
18
  Ask::Tools::Shell.all.map(&:name)
24
- # => ["bash", "read", "write", "edit", "glob", "grep", "code"]
19
+ # => ["bash", "read", "write", "edit", "glob", "grep", "code", "apply_patch"]
25
20
 
26
- # Use a tool standalone
27
21
  result = Ask::Tools::Bash.new.call(command: "echo hello")
28
- result.ok? # => true
29
- result.output[:stdout] # => "hello\n"
30
- result.output[:exit_code] # => 0
22
+ result.ok? # => true
23
+ result.output[:stdout] # => "hello\n"
24
+ result.output[:exit_code] # => 0
31
25
  ```
32
26
 
33
- ---
34
-
35
- ## Tools
36
-
37
- ### `Ask::Tools::Bash`
38
-
39
- Execute shell commands in a sandboxed temp directory.
27
+ ## The tools
40
28
 
41
- | Param | Type | Required | Default | Description |
42
- |-------|------|----------|---------|-------------|
43
- | `command` | `string` | Yes | | The bash command to execute |
44
- | `timeout` | `integer` | No | 30 | Timeout in seconds |
45
- | `workdir` | `string` | No | temp dir | Working directory |
29
+ | Tool | Parameters | Notes |
30
+ |---|---|---|
31
+ | `Ask::Tools::Bash` | `command`, `timeout` (30), `workdir` | Runs via `Ask::Sandbox.provider`; returns `{ stdout, stderr, exit_code, timed_out }`, output truncated to 100KB |
32
+ | `Ask::Tools::Read` | `path`, `offset` (0-indexed), `limit` (2000) | Reads files with line numbers, or lists a directory |
33
+ | `Ask::Tools::Write` | `path`, `content` | Creates parent directories automatically |
34
+ | `Ask::Tools::Edit` | `path`, `old_string`, `new_string`, `replace_all` | Exact string replacement |
35
+ | `Ask::Tools::Glob` | `pattern`, `path` | Up to 1000 files, newest first |
36
+ | `Ask::Tools::Grep` | `pattern`, `path`, `include` | Regex search; 100 matches max, skips `.git`, `node_modules`, `vendor`, `.bundle`, `tmp`, `log` |
37
+ | `Ask::Tools::Code` | `code` | Runs Ruby via `Ask::Sandbox.provider`; returns `{ stdout, stderr, exit_code }` |
38
+ | `Ask::Tools::ApplyPatch` | `patchText` | Applies unified diffs inside a `*** Begin Patch` / `*** End Patch` envelope (Add File, Update File, Delete File sections) |
46
39
 
47
- Returns `{ stdout, stderr, exit_code, timed_out }`. Output truncated to 100KB. Process killed on timeout.
40
+ ## Sandboxed execution
48
41
 
49
- ```ruby
50
- Ask::Tools::Bash.new.call(command: "ls -la", timeout: 10)
51
- ```
52
-
53
- ### `Ask::Tools::Read`
54
-
55
- Read file contents with line numbers, or list directory entries.
56
-
57
- | Param | Type | Required | Default | Description |
58
- |-------|------|----------|---------|-------------|
59
- | `path` | `string` | Yes | — | Absolute path to file or directory |
60
- | `offset` | `integer` | No | 0 | Starting line number (0-indexed) |
61
- | `limit` | `integer` | No | 2000 | Maximum lines to read |
42
+ `Bash` and `Code` run through `Ask::Sandbox.provider` (ask-sandbox-providers), which defaults to the Local provider. Switch to stronger isolation:
62
43
 
63
44
  ```ruby
64
- Ask::Tools::Read.new.call(path: "/etc/hosts")
65
- Ask::Tools::Read.new.call(path: "large.log", offset: 100, limit: 50)
45
+ Ask::Sandbox.provider = :docker
66
46
  ```
67
47
 
68
- ### `Ask::Tools::Write`
69
-
70
- Write content to a file. Creates parent directories automatically.
48
+ ## Full documentation
71
49
 
72
- | Param | Type | Required | Default | Description |
73
- |-------|------|----------|---------|-------------|
74
- | `path` | `string` | Yes | — | Absolute path to write to |
75
- | `content` | `string` | Yes | — | File content (max 500KB) |
76
-
77
- ```ruby
78
- Ask::Tools::Write.new.call(path: "/tmp/hello.txt", content: "Hello, World!")
79
- ```
80
-
81
- ### `Ask::Tools::Edit`
82
-
83
- Replace exact text in a file. Uses exact string matching.
84
-
85
- | Param | Type | Required | Default | Description |
86
- |-------|------|----------|---------|-------------|
87
- | `path` | `string` | Yes | — | Absolute path to the file |
88
- | `old_string` | `string` | Yes | — | Exact text to replace |
89
- | `new_string` | `string` | Yes | — | Replacement text |
90
- | `replace_all` | `boolean` | No | false | Replace all occurrences |
91
-
92
- ```ruby
93
- Ask::Tools::Edit.new.call(path: "file.rb", old_string: "foo", new_string: "bar")
94
- Ask::Tools::Edit.new.call(path: "file.rb", old_string: "x", new_string: "y", replace_all: true)
95
- ```
96
-
97
- ### `Ask::Tools::Glob`
98
-
99
- Find files matching a glob pattern, sorted by modification time (newest first).
100
-
101
- | Param | Type | Required | Default | Description |
102
- |-------|------|----------|---------|-------------|
103
- | `pattern` | `string` | Yes | — | Glob pattern (e.g. `**/*.rb`) |
104
- | `path` | `string` | No | current dir | Base directory |
105
-
106
- Max 1000 results.
107
-
108
- ```ruby
109
- Ask::Tools::Glob.new.call(pattern: "**/*.rb", path: "/path/to/project")
110
- ```
111
-
112
- ### `Ask::Tools::Grep`
113
-
114
- Search file contents using a regex pattern.
115
-
116
- | Param | Type | Required | Default | Description |
117
- |-------|------|----------|---------|-------------|
118
- | `pattern` | `string` | Yes | — | Regex pattern to search for |
119
- | `path` | `string` | No | current dir | Directory to search |
120
- | `include` | `string` | No | `**/*` | File pattern filter (e.g. `*.rb`) |
121
-
122
- Max 100 matches. Line content capped at 500 chars. Skips `.git`, `node_modules`, `vendor`, `.bundle`, `tmp`, `log`.
123
-
124
- ```ruby
125
- Ask::Tools::Grep.new.call(pattern: "TODO", path: ".", include: "*.rb")
126
- ```
127
-
128
- ### `Ask::Tools::Code`
129
-
130
- Write and execute Ruby code in a subprocess. Uses gems already available in the environment.
131
-
132
- | Param | Type | Required | Default | Description |
133
- |-------|------|----------|---------|-------------|
134
- | `code` | `string` | Yes | — | Ruby source code to execute |
135
-
136
- Returns `{ stdout, stderr, exit_code }`. Output truncated to 100KB.
137
-
138
- ```ruby
139
- Ask::Tools::Code.new.call(code: <<~RUBY)
140
- puts "Hello from Ruby!"
141
- result = 2 + 2
142
- puts "2 + 2 = #{result}"
143
- RUBY
144
- ```
145
-
146
- ---
147
-
148
- ## Using Tools with an Agent
149
-
150
- ```ruby
151
- require "ask-tools-shell"
152
-
153
- # All tools
154
- tools = Ask::Tools::Shell.all
155
-
156
- # Find by name
157
- bash = Ask::Tools["bash"]
158
- bash.call(command: "date")
159
- ```
160
-
161
- ---
50
+ The full ask-rb documentation lives at https://ask-rb.github.io/ask-docs. [ask-tools in depth](https://ask-rb.github.io/ask-docs/core/tools) covers the shell tools, the ApplyPatch format, and sandbox configuration. API reference: https://ask-rb.github.io/ask-docs/reference/api.
162
51
 
163
52
  ## Development
164
53
 
165
- ```bash
54
+ ```
166
55
  bundle install
167
56
  bundle exec rake test
168
- gem build ask-tools-shell.gemspec
169
57
  ```
170
58
 
171
59
  ## License
@@ -34,7 +34,13 @@ module Ask
34
34
 
35
35
  Dir.glob(File.join(base, glob)).each do |file|
36
36
  next unless File.file?(file)
37
- next if EXCLUDE_DIRS.any? { |d| file.include?("/#{d}/") }
37
+
38
+ # Exclude by path segment relative to the search root, not by
39
+ # absolute-path substring: the old check matched any path
40
+ # containing "/tmp/" (e.g. a search rooted under the system tmp
41
+ # dir on Linux), excluding every file.
42
+ relative = file.delete_prefix("#{base}/")
43
+ next if relative.split("/").any? { |seg| EXCLUDE_DIRS.include?(seg) }
38
44
 
39
45
  begin
40
46
  File.readlines(file).each_with_index do |line, i|
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Persistent Ruby kernel for Ask::Tools::Shell::Repl.
4
+ #
5
+ # A standalone plain-Ruby script run as a long-lived subprocess. Reads framed
6
+ # JSON requests from stdin, evaluates each snippet into a persistent binding,
7
+ # and writes a framed JSON response to stdout.
8
+ #
9
+ # Protocol (newline-delimited JSON):
10
+ # request: {"id": 1, "code": "1 + 1"}
11
+ # response: {"id": 1, "result": "2", "stdout": "", "stderr": "", "error": null}
12
+ #
13
+ # The binding persists across requests, so locals, requires, and defined
14
+ # methods survive between calls. stdout/stderr are captured per evaluation.
15
+
16
+ require "json"
17
+ require "stringio"
18
+
19
+ # Die immediately on TERM (sent by the parent to shut the session down).
20
+ # exit! bypasses the rescue Exception below, which would otherwise swallow
21
+ # the SignalException raised mid-eval and keep the kernel alive.
22
+ trap("TERM") { exit!(0) }
23
+
24
+ def read_frame
25
+ line = $stdin.gets
26
+ return nil if line.nil?
27
+
28
+ JSON.parse(line)
29
+ rescue JSON::ParserError
30
+ { "error" => "invalid request frame" }
31
+ end
32
+
33
+ def write_frame(frame)
34
+ $stdout.puts(JSON.generate(frame))
35
+ $stdout.flush
36
+ end
37
+
38
+ def capture_output
39
+ old_out = $stdout
40
+ old_err = $stderr
41
+ out = StringIO.new
42
+ err = StringIO.new
43
+ $stdout = out
44
+ $stderr = err
45
+ yield
46
+ [out.string, err.string]
47
+ ensure
48
+ $stdout = old_out
49
+ $stderr = old_err
50
+ end
51
+
52
+ def safe_inspect(value)
53
+ value.inspect
54
+ rescue StandardError
55
+ "#<#{value.class}>"
56
+ end
57
+
58
+ binding = TOPLEVEL_BINDING
59
+
60
+ loop do
61
+ request = read_frame
62
+ break if request.nil?
63
+
64
+ id = request["id"]
65
+ code = request["code"].to_s
66
+
67
+ result = nil
68
+ stdout = ""
69
+ stderr = ""
70
+ error = nil
71
+
72
+ begin
73
+ stdout, stderr = capture_output do
74
+ result = binding.eval(code)
75
+ end
76
+ rescue Exception => e # rubocop:disable Lint/RescueException
77
+ error = "#{e.class}: #{e.message}"
78
+ trace = (e.backtrace || []).first(10).join("\n")
79
+ stderr += trace
80
+ end
81
+
82
+ write_frame(
83
+ "id" => id,
84
+ "result" => safe_inspect(result),
85
+ "stdout" => stdout,
86
+ "stderr" => stderr,
87
+ "error" => error
88
+ )
89
+ end
@@ -0,0 +1,357 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "monitor"
5
+ require "io/wait"
6
+
7
+ module Ask
8
+ module Tools
9
+ # Evaluate Ruby code in a persistent, long-lived subprocess.
10
+ #
11
+ # Unlike {Code} (which spawns a fresh `ruby -e` per call), Repl keeps a
12
+ # kernel process alive across calls and evaluates every snippet into the
13
+ # same binding — so state (locals, requires, defined methods) survives
14
+ # between calls. This is the RLM (recursive language model) pattern: the
15
+ # model composes capabilities as code against a persistent environment.
16
+ #
17
+ # @example
18
+ # repl = Ask::Tools::Repl.new
19
+ # repl.call(code: 'require "json"; data = JSON.parse(%q({"a": 1}))')
20
+ # repl.call(code: "data['a'] + 1") # => 2 — `data` still exists
21
+ # repl.call(code: "def double(x); x * 2; end")
22
+ # repl.call(code: "double(21)") # => 42
23
+ #
24
+ # Sessions are named and shared process-wide: calling with the same
25
+ # +session+ name from any tool instance reaches the same kernel. A
26
+ # session is closed by +reset: true+, {Repl.close_session}, or after
27
+ # {Repl.idle_timeout} seconds without use.
28
+ #
29
+ # @note The kernel runs with the caller's permissions (like {Code}).
30
+ # It is a durable control environment, not a security sandbox.
31
+ class Repl < Ask::Tool
32
+ description "Evaluate Ruby code in a persistent session. " \
33
+ "State (variables, requires, defined methods) survives " \
34
+ "across calls in the same session. " \
35
+ "Use sessions to keep working context alive."
36
+
37
+ param :code, type: :string, desc: "Ruby source code to evaluate", required: true
38
+ param :session, type: :string, desc: "Session name; state persists per name", required: false
39
+ param :reset, type: :boolean, desc: "Discard session state before evaluating", required: false
40
+
41
+ # Timeout for a single evaluation, in seconds.
42
+ DEFAULT_EVAL_TIMEOUT = 30
43
+
44
+ # Sessions idle longer than this are closed on next access.
45
+ DEFAULT_IDLE_TIMEOUT = 300
46
+
47
+ # Env vars that would drag the parent's bundler context into the
48
+ # kernel subprocess (RUBYOPT=-rbundler/setup restricts $LOAD_PATH to
49
+ # the parent's Gemfile). Nil overrides remove them at spawn so the
50
+ # session is plain ruby, like the one-shot Code tool's sandbox.
51
+ BUNDLER_ENV = %w[
52
+ RUBYOPT RUBYLIB BASH_ENV GEM_PATH GEM_HOME
53
+ BUNDLE_GEMFILE BUNDLE_PATH BUNDLE_BIN_PATH BUNDLER_SETUP
54
+ BUNDLER_VERSION BUNDLE_WITHOUT BUNDLE_FROZEN BUNDLE_DEPLOYMENT
55
+ BUNDLE_LOCKFILE BUNDLE_APP_CONFIG
56
+ ].freeze
57
+
58
+ class << self
59
+ # @return [Integer] seconds a session may sit unused before it is
60
+ # closed on next access
61
+ attr_accessor :idle_timeout
62
+
63
+ # @return [Integer] seconds a single evaluation may take
64
+ attr_accessor :eval_timeout
65
+ end
66
+
67
+ self.idle_timeout = DEFAULT_IDLE_TIMEOUT
68
+ self.eval_timeout = DEFAULT_EVAL_TIMEOUT
69
+
70
+ @registry = {}
71
+ @registry_mutex = Monitor.new
72
+
73
+ class << self
74
+ # The kernel for +session+, spawning one if needed (or after reset).
75
+ #
76
+ # @param session [String]
77
+ # @param reset [Boolean] discard existing session state
78
+ # @return [Kernel]
79
+ def kernel_for(session, reset: false)
80
+ @registry_mutex.synchronize do
81
+ close_session(session) if reset
82
+ kernel = @registry[session]
83
+ if kernel.nil? || kernel.dead?
84
+ kernel = Kernel.new(session: session)
85
+ @registry[session] = kernel
86
+ elsif kernel.idle_seconds > idle_timeout
87
+ kernel.close
88
+ kernel = Kernel.new(session: session)
89
+ @registry[session] = kernel
90
+ end
91
+ kernel
92
+ end
93
+ end
94
+
95
+ # Close and forget a session's kernel.
96
+ #
97
+ # @param session [String]
98
+ # @return [void]
99
+ def close_session(session)
100
+ @registry_mutex.synchronize do
101
+ kernel = @registry.delete(session)
102
+ kernel&.close
103
+ end
104
+ nil
105
+ end
106
+
107
+ # Close every session kernel. Called at exit; call it explicitly to
108
+ # free subprocesses early.
109
+ #
110
+ # @return [void]
111
+ def close_all
112
+ @registry_mutex.synchronize do
113
+ @registry.each_value(&:close)
114
+ @registry.clear
115
+ end
116
+ nil
117
+ end
118
+
119
+ # @return [Array<String>] active session names
120
+ def sessions
121
+ @registry_mutex.synchronize { @registry.keys }
122
+ end
123
+ end
124
+
125
+ at_exit { Repl.close_all }
126
+
127
+ def execute(code:, session: "default", reset: false)
128
+ build_result(eval_code(session, code, reset: reset), session)
129
+ rescue Kernel::TimeoutError => e
130
+ Ask::Result.error(message: "REPL session '#{session}' timed out (#{e.timeout}s); session state was lost")
131
+ rescue Kernel::DeadError => e
132
+ # The session died (crash, external kill, closed stdin). Respawn a
133
+ # fresh kernel and retry once; only give up if it dies again.
134
+ begin
135
+ build_result(eval_code(session, code), session)
136
+ rescue Kernel::DeadError
137
+ Ask::Result.error(message: "REPL session '#{session}' died repeatedly: #{e.message}")
138
+ end
139
+ end
140
+
141
+ private
142
+
143
+ def eval_code(session, code, reset: false)
144
+ Repl.kernel_for(session, reset: reset).eval(code, timeout: Repl.eval_timeout)
145
+ end
146
+
147
+ def build_result(result, session)
148
+ if result["error"]
149
+ Ask::Result.error(
150
+ message: result["error"],
151
+ metadata: { session: session, stdout: result["stdout"], stderr: result["stderr"] }
152
+ )
153
+ else
154
+ Ask::Result.ok(data: {
155
+ result: result["result"],
156
+ stdout: result["stdout"],
157
+ stderr: result["stderr"],
158
+ session: session
159
+ })
160
+ end
161
+ end
162
+
163
+ # A single long-lived Ruby subprocess executing the kernel script.
164
+ #
165
+ # Communicates over newline-delimited JSON on stdin/stdout. One eval
166
+ # at a time per kernel; concurrent calls serialize on an internal
167
+ # monitor.
168
+ class Kernel
169
+ class Error < StandardError; end
170
+ class TimeoutError < Error
171
+ attr_reader :timeout
172
+
173
+ def initialize(timeout)
174
+ @timeout = timeout
175
+ super("evaluation exceeded #{timeout}s")
176
+ end
177
+ end
178
+ class DeadError < Error; end
179
+
180
+ # @return [String] session name this kernel belongs to
181
+ attr_reader :session
182
+
183
+ # @return [Time] last time an evaluation completed
184
+ attr_reader :last_used
185
+
186
+ def initialize(session:, ruby: "ruby")
187
+ @session = session
188
+ @script = File.expand_path("repl/kernel_script.rb", __dir__)
189
+ @monitor = Monitor.new
190
+ @last_used = Time.now
191
+ @next_id = 0
192
+ spawn_process(ruby)
193
+ end
194
+
195
+ # @return [Float] seconds since the last evaluation
196
+ def idle_seconds
197
+ Time.now - @last_used
198
+ end
199
+
200
+ # @return [Boolean] whether the kernel process is gone. Reaps the
201
+ # child if it already exited (zombies count as dead).
202
+ def dead?
203
+ return true if @closed
204
+ return true unless @pid
205
+
206
+ _, status = Process.waitpid(@pid, Process::WNOHANG)
207
+ if status.nil?
208
+ false
209
+ else
210
+ @pid = nil
211
+ true
212
+ end
213
+ rescue Errno::ECHILD, Errno::ESRCH, Errno::EINTR
214
+ @pid = nil
215
+ true
216
+ end
217
+
218
+ # Evaluate +code+ in the persistent binding.
219
+ #
220
+ # @param code [String]
221
+ # @param timeout [Integer] max seconds for this evaluation
222
+ # @return [Hash] {"result" => String, "stdout" => String,
223
+ # "stderr" => String, "error" => String or nil}
224
+ # @raise [TimeoutError] evaluation exceeded +timeout+; the kernel
225
+ # was killed and session state lost
226
+ # @raise [DeadError] the kernel process died
227
+ def eval(code, timeout: Repl.eval_timeout)
228
+ @monitor.synchronize do
229
+ raise DeadError, "process not running" if dead?
230
+
231
+ id = (@next_id += 1)
232
+ write_frame("id" => id, "code" => code)
233
+ response = read_frame(id, timeout)
234
+ @last_used = Time.now
235
+ response
236
+ end
237
+ end
238
+
239
+ # Terminate the kernel process and close pipes. TERM is normally
240
+ # enough (the kernel script exits on it); KILL is the fallback for
241
+ # user code that overrode the trap or wedged the VM.
242
+ #
243
+ # @return [void]
244
+ def close
245
+ @monitor.synchronize do
246
+ return if @closed
247
+
248
+ @closed = true
249
+ if @pid
250
+ begin
251
+ Process.kill("TERM", @pid)
252
+ rescue Errno::ESRCH, Errno::ECHILD
253
+ @pid = nil
254
+ end
255
+ wait_for_exit(2)
256
+ if @pid
257
+ begin
258
+ Process.kill("KILL", @pid)
259
+ rescue Errno::ESRCH, Errno::ECHILD
260
+ @pid = nil
261
+ end
262
+ wait_for_exit(1)
263
+ end
264
+ end
265
+ @in_w.close unless @in_w.closed?
266
+ @out_r.close unless @out_r.closed?
267
+ @err_r.close unless @err_r.closed?
268
+ end
269
+ nil
270
+ end
271
+
272
+ private
273
+
274
+ # Poll until the child exits or +seconds+ elapse. Reaps the child
275
+ # when it does. Avoids blocking +Process.wait+ which Timeout cannot
276
+ # always interrupt.
277
+ #
278
+ # @param seconds [Numeric]
279
+ # @return [void]
280
+ def wait_for_exit(seconds)
281
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
282
+ loop do
283
+ _, status = Process.waitpid(@pid, Process::WNOHANG)
284
+ return if status
285
+
286
+ break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
287
+
288
+ sleep 0.01
289
+ end
290
+ rescue Errno::ECHILD, Errno::ESRCH, Errno::EINTR
291
+ @pid = nil
292
+ end
293
+
294
+ def spawn_process(ruby)
295
+ @in_r, @in_w = IO.pipe
296
+ @out_r, @out_w = IO.pipe
297
+ @err_r, @err_w = IO.pipe
298
+
299
+ # The kernel is a plain ruby environment: remove bundler plumbing
300
+ # inherited from the parent (RUBYOPT=-rbundler/setup etc.) so the
301
+ # session sees globally installed gems, not the parent's Gemfile
302
+ # subset. Note: Process.spawn *merges* its env hash with the
303
+ # parent environment — only explicit nil values delete keys.
304
+ env = BUNDLER_ENV.to_h { |key| [key, nil] }
305
+
306
+ @pid = Process.spawn(
307
+ env, ruby, @script,
308
+ in: @in_r, out: @out_w, err: @err_w
309
+ )
310
+ @in_r.close
311
+ @out_w.close
312
+ @err_w.close
313
+ rescue StandardError
314
+ close
315
+ raise
316
+ end
317
+
318
+ def write_frame(frame)
319
+ @in_w.write(JSON.generate(frame) + "\n")
320
+ @in_w.flush
321
+ rescue Errno::EPIPE, IOError => e
322
+ raise DeadError, e.message
323
+ end
324
+
325
+ def read_frame(expected_id, timeout)
326
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
327
+
328
+ loop do
329
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
330
+ if remaining <= 0
331
+ close
332
+ raise TimeoutError, timeout
333
+ end
334
+
335
+ ready = @out_r.wait_readable(remaining)
336
+ unless ready
337
+ close
338
+ raise TimeoutError, timeout
339
+ end
340
+
341
+ line = @out_r.gets
342
+ if line.nil?
343
+ err = @err_r.read
344
+ close
345
+ raise DeadError, "kernel exited unexpectedly#{err.empty? ? "" : ": #{err.strip}"}"
346
+ end
347
+
348
+ frame = JSON.parse(line)
349
+ return frame if frame["id"] == expected_id
350
+ rescue JSON::ParserError
351
+ next
352
+ end
353
+ end
354
+ end
355
+ end
356
+ end
357
+ end
@@ -3,7 +3,7 @@
3
3
  module Ask
4
4
  module Tools
5
5
  module Shell
6
- VERSION = "0.3.4"
6
+ VERSION = "0.4.0"
7
7
  end
8
8
  end
9
9
  end
@@ -10,11 +10,12 @@ require_relative "shell/glob"
10
10
  require_relative "shell/grep"
11
11
  require_relative "shell/code"
12
12
  require_relative "shell/apply_patch"
13
+ require_relative "shell/repl"
13
14
 
14
15
  module Ask
15
16
  module Tools
16
17
  module Shell
17
- TOOLS = [Bash, Read, Write, Edit, Glob, Grep, Code, ApplyPatch].freeze
18
+ TOOLS = [Bash, Read, Write, Edit, Glob, Grep, Code, ApplyPatch, Repl].freeze
18
19
 
19
20
  def self.all
20
21
  TOOLS.map(&:new)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ask-tools-shell
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.4
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -100,6 +100,8 @@ files:
100
100
  - lib/ask/tools/shell/glob.rb
101
101
  - lib/ask/tools/shell/grep.rb
102
102
  - lib/ask/tools/shell/read.rb
103
+ - lib/ask/tools/shell/repl.rb
104
+ - lib/ask/tools/shell/repl/kernel_script.rb
103
105
  - lib/ask/tools/shell/version.rb
104
106
  - lib/ask/tools/shell/write.rb
105
107
  homepage: https://github.com/ask-rb/ask-tools-shell