kward 0.82.0 → 0.83.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 +4 -4
- data/CHANGELOG.md +28 -0
- data/Gemfile.lock +2 -2
- data/doc/agent-tools.md +1 -0
- data/doc/composer.md +1 -1
- data/doc/configuration.md +18 -3
- data/doc/editor.md +20 -10
- data/doc/files.md +4 -3
- data/doc/permissions.md +1 -0
- data/doc/releasing.md +12 -4
- data/doc/rpc.md +2 -2
- data/doc/sandboxing.md +5 -1
- data/doc/security.md +1 -1
- data/doc/shell.md +40 -16
- data/doc/tabs.md +3 -0
- data/doc/usage.md +3 -2
- data/lib/kward/agent.rb +1 -0
- data/lib/kward/auth/anthropic_oauth.rb +7 -7
- data/lib/kward/cli/interactive_turn.rb +101 -19
- data/lib/kward/cli/runtime_helpers.rb +156 -14
- data/lib/kward/cli/slash_commands.rb +12 -1
- data/lib/kward/cli/tabs.rb +12 -5
- data/lib/kward/cli.rb +10 -0
- data/lib/kward/compaction/token_estimator.rb +12 -6
- data/lib/kward/config_files.rb +17 -0
- data/lib/kward/editor_prompt.rb +46 -0
- data/lib/kward/editor_prompt_session.rb +28 -0
- data/lib/kward/ekwsh.rb +70 -10
- data/lib/kward/model/client.rb +1 -17
- data/lib/kward/model/model_info.rb +3 -2
- data/lib/kward/model/payloads.rb +0 -2
- data/lib/kward/persistent_shell_session.rb +750 -0
- data/lib/kward/project_files.rb +18 -5
- data/lib/kward/prompt_interface/composer_renderer.rb +1 -1
- data/lib/kward/prompt_interface/editor/auto_indent.rb +3 -0
- data/lib/kward/prompt_interface/editor/controller.rb +69 -0
- data/lib/kward/prompt_interface/editor/modes/modern.rb +4 -0
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +10 -4
- data/lib/kward/prompt_interface/editor/renderer.rb +1 -0
- data/lib/kward/prompt_interface/editor/state.rb +11 -0
- data/lib/kward/prompt_interface/editor/word_completion.rb +124 -0
- data/lib/kward/prompt_interface/file_overlay.rb +21 -7
- data/lib/kward/prompt_interface/key_handler.rb +8 -0
- data/lib/kward/prompt_interface/project_browser.rb +30 -5
- data/lib/kward/prompt_interface/runtime_state.rb +5 -1
- data/lib/kward/prompt_interface.rb +76 -0
- data/lib/kward/prompts/commands.rb +1 -0
- data/lib/kward/rpc/auth_manager.rb +1 -1
- data/lib/kward/rpc/server.rb +2 -1
- data/lib/kward/shell_prompt.rb +50 -0
- data/lib/kward/shell_prompt_session.rb +58 -0
- data/lib/kward/terminal_keys.rb +1 -0
- data/lib/kward/tools/prepare_shell_command.rb +28 -0
- data/lib/kward/tools/registry.rb +63 -5
- data/lib/kward/tools/replace_editor_buffer.rb +30 -0
- data/lib/kward/tools/run_shell_command.rb +24 -6
- data/lib/kward/version.rb +1 -1
- data/templates/default/layout/html/layout.erb +1 -1
- metadata +9 -1
|
@@ -0,0 +1,750 @@
|
|
|
1
|
+
require "io/console"
|
|
2
|
+
require "pty"
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "shellwords"
|
|
5
|
+
require_relative "ansi"
|
|
6
|
+
require_relative "ekwsh"
|
|
7
|
+
require_relative "local_pty_command_runner"
|
|
8
|
+
|
|
9
|
+
# Namespace for the Kward CLI agent runtime.
|
|
10
|
+
module Kward
|
|
11
|
+
# Owns one interactive shell process and serializes user and agent commands.
|
|
12
|
+
#
|
|
13
|
+
# The process is kept alive between commands so shell variables, functions,
|
|
14
|
+
# aliases, directory changes, and child-shell state belong to one session.
|
|
15
|
+
class PersistentShellSession
|
|
16
|
+
Result = Ekwsh::Result
|
|
17
|
+
InteractiveResult = Struct.new(:exit_status, :input_forwarded, keyword_init: true)
|
|
18
|
+
|
|
19
|
+
class CommandInterrupted < StandardError
|
|
20
|
+
attr_reader :protocol
|
|
21
|
+
|
|
22
|
+
def initialize(protocol)
|
|
23
|
+
@protocol = protocol
|
|
24
|
+
super("The embedded shell did not respond to an interrupt.")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
READ_SIZE = 4_096
|
|
29
|
+
STARTUP_TIMEOUT_SECONDS = 5
|
|
30
|
+
STTY_COMMAND = if File.executable?("/bin/stty")
|
|
31
|
+
"/bin/stty"
|
|
32
|
+
elsif File.executable?("/usr/bin/stty")
|
|
33
|
+
"/usr/bin/stty"
|
|
34
|
+
else
|
|
35
|
+
"stty"
|
|
36
|
+
end.freeze
|
|
37
|
+
STATEFUL_COMMANDS = %w[alias cd export pwd unset unalias].freeze
|
|
38
|
+
|
|
39
|
+
attr_reader :cwd, :last_command, :timeout_seconds
|
|
40
|
+
|
|
41
|
+
def initialize(cwd: Dir.pwd, env: ENV.to_h, shell: Ekwsh::DEFAULT_SHELL, configured_env: {}, aliases: {}, timeout_seconds: Ekwsh::DEFAULT_TIMEOUT_SECONDS, max_output_bytes: Ekwsh::DEFAULT_MAX_OUTPUT_BYTES, window_size_provider: nil)
|
|
42
|
+
@cwd = File.expand_path(cwd.to_s.empty? ? Dir.pwd : cwd.to_s)
|
|
43
|
+
@base_env = env.to_h.transform_keys(&:to_s).transform_values(&:to_s)
|
|
44
|
+
@configured_env = configured_env.to_h.transform_keys(&:to_s).transform_values(&:to_s)
|
|
45
|
+
@aliases = aliases.to_h.transform_keys(&:to_s).transform_values(&:to_s)
|
|
46
|
+
@shell = shell.to_s.empty? ? Ekwsh::DEFAULT_SHELL : shell.to_s
|
|
47
|
+
@timeout_seconds = timeout_seconds.to_i.positive? ? timeout_seconds.to_i : Ekwsh::DEFAULT_TIMEOUT_SECONDS
|
|
48
|
+
@max_output_bytes = max_output_bytes.to_i.positive? ? max_output_bytes.to_i : Ekwsh::DEFAULT_MAX_OUTPUT_BYTES
|
|
49
|
+
@window_size_provider = window_size_provider
|
|
50
|
+
@last_command = nil
|
|
51
|
+
@last_result = nil
|
|
52
|
+
@command_mutex = Mutex.new
|
|
53
|
+
@write_mutex = Mutex.new
|
|
54
|
+
@marker_counter = 0
|
|
55
|
+
@marker_prefix = "__KWARD_SHELL_#{SecureRandom.hex(16)}_"
|
|
56
|
+
@closed = false
|
|
57
|
+
@routing_shell = build_routing_shell
|
|
58
|
+
@env = @routing_shell.child_env
|
|
59
|
+
|
|
60
|
+
start_process
|
|
61
|
+
initialize_protocol
|
|
62
|
+
rescue StandardError
|
|
63
|
+
close
|
|
64
|
+
raise
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def command_shell
|
|
68
|
+
@shell
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def child_env(interactive: false)
|
|
72
|
+
@env.dup
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def prompt_label
|
|
76
|
+
"Shell #{display_cwd} $"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def run(input, cancellation: nil, &block)
|
|
80
|
+
command = input.to_s.strip
|
|
81
|
+
return Result.new(output: "", exit_status: 0) if command.empty?
|
|
82
|
+
|
|
83
|
+
expanded_command = expand_alias(command)
|
|
84
|
+
words = shell_words(expanded_command)
|
|
85
|
+
sync_runtime_state(words)
|
|
86
|
+
result = if words.empty?
|
|
87
|
+
Result.new(output: "", exit_status: 0)
|
|
88
|
+
elsif words.first == "clear"
|
|
89
|
+
Result.new(output: "", exit_status: 0, clear: true)
|
|
90
|
+
elsif %w[exit logout].include?(words.first)
|
|
91
|
+
exit_result(command, words)
|
|
92
|
+
elsif words.first == "capture"
|
|
93
|
+
capture_result(command, expanded_command, cancellation: cancellation, &block)
|
|
94
|
+
elsif words.first == "pty"
|
|
95
|
+
interactive_result(command, expanded_command)
|
|
96
|
+
elsif (editor_result = editor_command_result(command))
|
|
97
|
+
editor_result
|
|
98
|
+
elsif stateful_command?(words)
|
|
99
|
+
execute_command(expanded_command, display_command: command, cancellation: cancellation, &block)
|
|
100
|
+
else
|
|
101
|
+
Result.new(output: command_echo(command), exit_status: 0, interactive_command: expanded_command)
|
|
102
|
+
end
|
|
103
|
+
remember_result(command, result)
|
|
104
|
+
result
|
|
105
|
+
rescue ArgumentError => e
|
|
106
|
+
result = Result.new(output: "#{command_echo(command)}ekwsh: #{e.message}\n", exit_status: 2)
|
|
107
|
+
remember_result(command, result)
|
|
108
|
+
result
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Runs a command for the shell assistant without handing it the terminal.
|
|
112
|
+
def run_for_agent(input, timeout_seconds: nil, cancellation: nil)
|
|
113
|
+
command = input.to_s.strip
|
|
114
|
+
return run(command, cancellation: cancellation) if command.empty?
|
|
115
|
+
|
|
116
|
+
result = run(command, cancellation: cancellation)
|
|
117
|
+
return result unless result.interactive_command
|
|
118
|
+
|
|
119
|
+
result = execute_command(
|
|
120
|
+
result.interactive_command,
|
|
121
|
+
display_command: command,
|
|
122
|
+
timeout_seconds: timeout_seconds,
|
|
123
|
+
cancellation: cancellation,
|
|
124
|
+
suppress_git_pager: true
|
|
125
|
+
)
|
|
126
|
+
remember_result(command, result)
|
|
127
|
+
result
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Runs an external command through the same persistent shell process while
|
|
131
|
+
# the caller owns the terminal.
|
|
132
|
+
def run_interactive(command, input:, sink:)
|
|
133
|
+
protocol = with_raw_input(input) do
|
|
134
|
+
execute_protocol(command, input: input, sink: sink)
|
|
135
|
+
end
|
|
136
|
+
update_cwd(protocol[:cwd])
|
|
137
|
+
InteractiveResult.new(
|
|
138
|
+
exit_status: protocol[:status] || 1,
|
|
139
|
+
input_forwarded: protocol[:input_forwarded]
|
|
140
|
+
)
|
|
141
|
+
ensure
|
|
142
|
+
sink.finish if sink.respond_to?(:finish)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Records the safe output captured after an interactive terminal handoff.
|
|
146
|
+
def record_interactive_result(output:, exit_status:)
|
|
147
|
+
return unless @last_command
|
|
148
|
+
|
|
149
|
+
@last_result = Result.new(output: ANSI.sanitize_transcript(output.to_s), exit_status: exit_status)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Returns bounded shell state for an explicit shell-agent prompt.
|
|
153
|
+
def context_snapshot(max_output_bytes: Ekwsh::CONTEXT_OUTPUT_BYTES)
|
|
154
|
+
{
|
|
155
|
+
cwd: @cwd,
|
|
156
|
+
last_command: @last_command,
|
|
157
|
+
exit_status: @last_result&.exit_status,
|
|
158
|
+
last_output: context_output(@last_result&.output, max_output_bytes)
|
|
159
|
+
}
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def complete(input, cursor)
|
|
163
|
+
completion_shell.complete(input, cursor)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def expand_alias(command, interactive: false)
|
|
167
|
+
@routing_shell.expand_alias(command, interactive: interactive)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def editor_command_result(command, display_command: command)
|
|
171
|
+
@routing_shell.editor_command_result(command, display_command: display_command)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def close
|
|
175
|
+
return if @closed
|
|
176
|
+
|
|
177
|
+
@closed = true
|
|
178
|
+
begin
|
|
179
|
+
terminate_process_group(@pid) if @pid
|
|
180
|
+
ensure
|
|
181
|
+
@write_mutex.synchronize do
|
|
182
|
+
@reader&.close unless @reader&.closed?
|
|
183
|
+
@writer&.close unless @writer&.closed?
|
|
184
|
+
end
|
|
185
|
+
@process_thread&.join(0.5)
|
|
186
|
+
@process_thread&.kill if @process_thread&.alive?
|
|
187
|
+
@pid = nil
|
|
188
|
+
end
|
|
189
|
+
rescue StandardError
|
|
190
|
+
nil
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
private
|
|
194
|
+
|
|
195
|
+
def with_raw_input(input)
|
|
196
|
+
return yield unless input.respond_to?(:raw)
|
|
197
|
+
|
|
198
|
+
yielded = false
|
|
199
|
+
input.raw do
|
|
200
|
+
yielded = true
|
|
201
|
+
yield
|
|
202
|
+
end
|
|
203
|
+
rescue Errno::ENOTTY
|
|
204
|
+
raise if yielded
|
|
205
|
+
|
|
206
|
+
yield
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def build_routing_shell
|
|
210
|
+
Ekwsh.new(
|
|
211
|
+
cwd: @cwd,
|
|
212
|
+
env: @env || @base_env,
|
|
213
|
+
shell: @shell,
|
|
214
|
+
configured_env: @env ? {} : @configured_env,
|
|
215
|
+
aliases: @aliases,
|
|
216
|
+
timeout_seconds: @timeout_seconds,
|
|
217
|
+
max_output_bytes: @max_output_bytes
|
|
218
|
+
)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def completion_shell
|
|
222
|
+
@completion_shell ||= build_routing_shell
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def start_process
|
|
226
|
+
ready = Queue.new
|
|
227
|
+
process_env = @env.merge("PS1" => "", "PS2" => "")
|
|
228
|
+
@process_thread = Thread.new do
|
|
229
|
+
begin
|
|
230
|
+
PTY.spawn(process_env, @shell, "-i", chdir: @cwd) do |reader, writer, pid|
|
|
231
|
+
@reader = reader
|
|
232
|
+
@writer = writer
|
|
233
|
+
@pid = pid
|
|
234
|
+
ready << true
|
|
235
|
+
sleep 0.05 until @closed
|
|
236
|
+
end
|
|
237
|
+
rescue StandardError => e
|
|
238
|
+
ready << e
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
started = ready.pop
|
|
242
|
+
raise started if started.is_a?(Exception)
|
|
243
|
+
|
|
244
|
+
@reader.sync = true
|
|
245
|
+
@writer.sync = true
|
|
246
|
+
update_window_size
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def initialize_protocol
|
|
250
|
+
protocol = execute_protocol(":", timeout_seconds: STARTUP_TIMEOUT_SECONDS, max_output_bytes: nil)
|
|
251
|
+
raise IOError, "The embedded shell did not start." unless protocol[:status] == 0
|
|
252
|
+
|
|
253
|
+
update_cwd(protocol[:cwd])
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def execute_protocol(command, input: nil, sink: nil, timeout_seconds: nil, max_output_bytes: @max_output_bytes, cancellation: nil, suppress_git_pager: false)
|
|
257
|
+
@command_mutex.synchronize do
|
|
258
|
+
ensure_open
|
|
259
|
+
cancellation&.raise_if_cancelled!
|
|
260
|
+
marker = next_marker
|
|
261
|
+
write_protocol_command(command, marker, suppress_git_pager: suppress_git_pager)
|
|
262
|
+
read_until_marker(
|
|
263
|
+
marker,
|
|
264
|
+
input: input,
|
|
265
|
+
sink: sink,
|
|
266
|
+
timeout_seconds: timeout_seconds,
|
|
267
|
+
max_output_bytes: max_output_bytes,
|
|
268
|
+
cancellation: cancellation
|
|
269
|
+
)
|
|
270
|
+
end
|
|
271
|
+
rescue CommandInterrupted => e
|
|
272
|
+
restart_process
|
|
273
|
+
e.protocol
|
|
274
|
+
rescue IOError
|
|
275
|
+
close
|
|
276
|
+
raise
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def write_protocol_command(command, marker, suppress_git_pager: false)
|
|
280
|
+
escaped_command = Shellwords.escape(command.to_s)
|
|
281
|
+
pager_setup, pager_restore = git_pager_protocol(marker, suppress: suppress_git_pager)
|
|
282
|
+
protocol = <<~SHELL
|
|
283
|
+
if true; then
|
|
284
|
+
if [ -n "${ZSH_VERSION-}" ]; then unsetopt zle 2>/dev/null; fi
|
|
285
|
+
PS1=''
|
|
286
|
+
PS2=''
|
|
287
|
+
export PS1 PS2
|
|
288
|
+
#{STTY_COMMAND} echo
|
|
289
|
+
#{pager_setup}
|
|
290
|
+
if eval #{escaped_command}; then __kward_status=$?; else __kward_status=$?; fi
|
|
291
|
+
#{pager_restore}
|
|
292
|
+
__kward_cwd=$(pwd 2>/dev/null)
|
|
293
|
+
#{STTY_COMMAND} -echo
|
|
294
|
+
printf '#{marker}:%s:%s\\n' "$__kward_status" "$__kward_cwd"
|
|
295
|
+
fi
|
|
296
|
+
SHELL
|
|
297
|
+
write_raw(protocol)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def git_pager_protocol(marker, suppress:)
|
|
301
|
+
return ["", ""] unless suppress
|
|
302
|
+
|
|
303
|
+
was_set = "#{marker}_GIT_PAGER_WAS_SET"
|
|
304
|
+
saved_value = "#{marker}_GIT_PAGER_VALUE"
|
|
305
|
+
setup = <<~SHELL
|
|
306
|
+
#{was_set}=${GIT_PAGER+x}
|
|
307
|
+
#{saved_value}=${GIT_PAGER-}
|
|
308
|
+
GIT_PAGER=cat
|
|
309
|
+
export GIT_PAGER
|
|
310
|
+
SHELL
|
|
311
|
+
restore = <<~SHELL
|
|
312
|
+
if [ "$#{was_set}" = x ]; then
|
|
313
|
+
GIT_PAGER="$#{saved_value}"
|
|
314
|
+
export GIT_PAGER
|
|
315
|
+
else
|
|
316
|
+
unset GIT_PAGER
|
|
317
|
+
fi
|
|
318
|
+
unset #{was_set} #{saved_value}
|
|
319
|
+
SHELL
|
|
320
|
+
[setup, restore]
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def read_until_marker(marker, input:, sink:, timeout_seconds:, max_output_bytes:, cancellation:)
|
|
324
|
+
buffer = +"".b
|
|
325
|
+
captured = +"".b
|
|
326
|
+
captured_bytes = 0
|
|
327
|
+
truncated = false
|
|
328
|
+
input_forwarded = false
|
|
329
|
+
timed_out = false
|
|
330
|
+
interrupted = false
|
|
331
|
+
deadline = timeout_seconds && (Time.now + effective_timeout(timeout_seconds))
|
|
332
|
+
|
|
333
|
+
loop do
|
|
334
|
+
if cancellation&.cancelled? && !interrupted
|
|
335
|
+
interrupted = true
|
|
336
|
+
interrupt_process
|
|
337
|
+
deadline = Time.now + 1
|
|
338
|
+
end
|
|
339
|
+
if deadline && Time.now >= deadline
|
|
340
|
+
unless interrupted
|
|
341
|
+
interrupted = true
|
|
342
|
+
timed_out = true
|
|
343
|
+
interrupt_process
|
|
344
|
+
deadline = Time.now + 1
|
|
345
|
+
else
|
|
346
|
+
interrupted_protocol = {
|
|
347
|
+
output: captured,
|
|
348
|
+
status: 130,
|
|
349
|
+
cwd: @cwd,
|
|
350
|
+
input_forwarded: input_forwarded,
|
|
351
|
+
timed_out: timed_out,
|
|
352
|
+
truncated: truncated
|
|
353
|
+
}
|
|
354
|
+
raise CommandInterrupted.new(interrupted_protocol)
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
readers = [@reader]
|
|
359
|
+
readers << input if input
|
|
360
|
+
readable = IO.select(readers, nil, nil, 0.02)&.first || []
|
|
361
|
+
if readable.include?(@reader)
|
|
362
|
+
chunk = read_chunk
|
|
363
|
+
if chunk.nil?
|
|
364
|
+
raise IOError, "The embedded shell exited before completing the command."
|
|
365
|
+
end
|
|
366
|
+
unless chunk == :wait_readable
|
|
367
|
+
buffer << chunk
|
|
368
|
+
end
|
|
369
|
+
if chunk == :wait_readable
|
|
370
|
+
next
|
|
371
|
+
end
|
|
372
|
+
parsed = extract_marker(buffer, marker)
|
|
373
|
+
if parsed
|
|
374
|
+
before, status, cwd = parsed
|
|
375
|
+
captured_bytes, truncated = emit_output(
|
|
376
|
+
before,
|
|
377
|
+
sink,
|
|
378
|
+
captured,
|
|
379
|
+
captured_bytes,
|
|
380
|
+
truncated,
|
|
381
|
+
max_output_bytes
|
|
382
|
+
)
|
|
383
|
+
drain_prompt_output
|
|
384
|
+
return {
|
|
385
|
+
output: captured,
|
|
386
|
+
status: status,
|
|
387
|
+
cwd: cwd,
|
|
388
|
+
input_forwarded: input_forwarded,
|
|
389
|
+
timed_out: timed_out,
|
|
390
|
+
truncated: truncated
|
|
391
|
+
}
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
flush_bytes = flushable_output_bytes(buffer, marker)
|
|
395
|
+
if flush_bytes.positive?
|
|
396
|
+
chunk_to_emit = buffer.byteslice(0, flush_bytes)
|
|
397
|
+
buffer = buffer.byteslice(flush_bytes..).to_s.b
|
|
398
|
+
captured_bytes, truncated = emit_output(
|
|
399
|
+
chunk_to_emit,
|
|
400
|
+
sink,
|
|
401
|
+
captured,
|
|
402
|
+
captured_bytes,
|
|
403
|
+
truncated,
|
|
404
|
+
max_output_bytes
|
|
405
|
+
)
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
if input && readable.include?(input)
|
|
410
|
+
input_chunk = input.read_nonblock(READ_SIZE, exception: false)
|
|
411
|
+
if input_chunk.nil?
|
|
412
|
+
input = nil
|
|
413
|
+
elsif input_chunk != :wait_readable
|
|
414
|
+
notify_input_forwarded(sink)
|
|
415
|
+
write_raw(input_chunk)
|
|
416
|
+
input_forwarded = true
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
update_window_size
|
|
420
|
+
end
|
|
421
|
+
rescue Errno::EIO
|
|
422
|
+
raise IOError, "The embedded shell exited before completing the command."
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def extract_marker(buffer, marker)
|
|
426
|
+
match = buffer.match(/#{Regexp.escape(marker)}:(-?\d+):(.*?)\r?\n/m)
|
|
427
|
+
return nil unless match
|
|
428
|
+
|
|
429
|
+
before = buffer.byteslice(0, match.begin(0)).to_s
|
|
430
|
+
[before, match[1].to_i, match[2].to_s]
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def flushable_output_bytes(buffer, marker)
|
|
434
|
+
candidate_start = buffer.rindex(marker.getbyte(0).chr)
|
|
435
|
+
if candidate_start
|
|
436
|
+
candidate = buffer.byteslice(candidate_start..).to_s
|
|
437
|
+
return candidate_start if marker.start_with?(candidate) || candidate.start_with?(marker)
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
max_prefix = [buffer.bytesize, marker.bytesize - 1].min
|
|
441
|
+
suffix_length = (1..max_prefix).reverse_each.find do |length|
|
|
442
|
+
buffer.end_with?(marker.byteslice(0, length))
|
|
443
|
+
end || 0
|
|
444
|
+
buffer.bytesize - suffix_length
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def drain_prompt_output
|
|
448
|
+
loop do
|
|
449
|
+
readable = IO.select([@reader], nil, nil, 0.01)&.first || []
|
|
450
|
+
break unless readable.include?(@reader)
|
|
451
|
+
|
|
452
|
+
chunk = read_chunk
|
|
453
|
+
break if chunk.nil? || chunk == :wait_readable
|
|
454
|
+
end
|
|
455
|
+
rescue IOError, Errno::EIO
|
|
456
|
+
nil
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def emit_output(text, sink, captured, captured_bytes, truncated, max_output_bytes)
|
|
460
|
+
return [captured_bytes, truncated] if text.to_s.empty?
|
|
461
|
+
|
|
462
|
+
if sink
|
|
463
|
+
sink.write(text)
|
|
464
|
+
sink.flush if sink.respond_to?(:flush)
|
|
465
|
+
return [captured_bytes, truncated]
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
return [captured_bytes, truncated] unless max_output_bytes
|
|
469
|
+
|
|
470
|
+
remaining = max_output_bytes - captured_bytes
|
|
471
|
+
if remaining <= 0
|
|
472
|
+
return [captured_bytes, true]
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
bytes = text.byteslice(0, remaining).to_s
|
|
476
|
+
captured << bytes
|
|
477
|
+
truncated ||= bytes.bytesize < text.bytesize
|
|
478
|
+
[captured_bytes + bytes.bytesize, truncated]
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def notify_input_forwarded(sink)
|
|
482
|
+
sink.input_forwarded if sink.respond_to?(:input_forwarded)
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def execute_command(command, display_command:, timeout_seconds: nil, cancellation: nil, suppress_git_pager: false, &block)
|
|
486
|
+
protocol = execute_protocol(
|
|
487
|
+
command,
|
|
488
|
+
timeout_seconds: timeout_seconds || @timeout_seconds,
|
|
489
|
+
cancellation: cancellation,
|
|
490
|
+
suppress_git_pager: suppress_git_pager
|
|
491
|
+
)
|
|
492
|
+
output = command_echo(display_command)
|
|
493
|
+
output << clean_output(protocol[:output])
|
|
494
|
+
append_output_newline(output)
|
|
495
|
+
status = protocol[:status] || 1
|
|
496
|
+
if protocol[:timed_out]
|
|
497
|
+
output << "ekwsh: command timed out after #{effective_timeout(timeout_seconds)} seconds\n"
|
|
498
|
+
status = 1
|
|
499
|
+
elsif protocol[:truncated]
|
|
500
|
+
output << "ekwsh: output exceeded #{@max_output_bytes} bytes; output truncated\n"
|
|
501
|
+
status = 1
|
|
502
|
+
elsif cancellation&.cancelled?
|
|
503
|
+
output << "^C\n" unless output.end_with?("^C\n")
|
|
504
|
+
status = 130
|
|
505
|
+
end
|
|
506
|
+
output << "Exit status: #{status}\n" unless status.zero?
|
|
507
|
+
update_cwd(protocol[:cwd])
|
|
508
|
+
block&.call(output)
|
|
509
|
+
Result.new(output: output, exit_status: status, streamed: block_given?)
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
def capture_result(command, expanded_command, cancellation: nil, &block)
|
|
513
|
+
captured_command = expanded_command.sub(/\A\s*capture(?:\s+|\z)/, "")
|
|
514
|
+
if captured_command.empty?
|
|
515
|
+
return Result.new(output: "#{command_echo(command)}Usage: capture <command>\n", exit_status: 2)
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
execute_command(
|
|
519
|
+
captured_command,
|
|
520
|
+
display_command: command,
|
|
521
|
+
cancellation: cancellation,
|
|
522
|
+
suppress_git_pager: true,
|
|
523
|
+
&block
|
|
524
|
+
)
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
def interactive_result(command, expanded_command)
|
|
528
|
+
interactive_command = expanded_command.sub(/\A\s*pty(?:\s+|\z)/, "")
|
|
529
|
+
if interactive_command.empty?
|
|
530
|
+
return Result.new(output: "#{command_echo(command)}Usage: pty <command>\n", exit_status: 2)
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
Result.new(output: command_echo(command), exit_status: 0, interactive_command: interactive_command)
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
def exit_result(command, words)
|
|
537
|
+
if words.length > 2 || (words[1] && !words[1].match?(/\A\d+\z/))
|
|
538
|
+
return Result.new(output: "#{command_echo(command)}ekwsh: #{words.first}: numeric status expected\n", exit_status: 2)
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
Result.new(output: command_echo(command), exit_status: words[1].to_i, exit_shell: true)
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
def sync_runtime_state(words)
|
|
545
|
+
changed = sync_runtime_aliases(words) || sync_runtime_environment(words)
|
|
546
|
+
return unless changed
|
|
547
|
+
|
|
548
|
+
@routing_shell = build_routing_shell
|
|
549
|
+
@completion_shell = nil
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
def sync_runtime_aliases(words)
|
|
553
|
+
case words.first
|
|
554
|
+
when "alias"
|
|
555
|
+
before = @aliases.dup
|
|
556
|
+
words.drop(1).each do |assignment|
|
|
557
|
+
name, value = assignment.split("=", 2)
|
|
558
|
+
@aliases[name] = value.to_s if value && Ekwsh.valid_alias_name?(name)
|
|
559
|
+
end
|
|
560
|
+
before != @aliases
|
|
561
|
+
when "unalias"
|
|
562
|
+
before = @aliases.dup
|
|
563
|
+
if words == ["unalias", "-a"]
|
|
564
|
+
@aliases.clear
|
|
565
|
+
else
|
|
566
|
+
words.drop(1).reject { |name| name.start_with?("-") }.each { |name| @aliases.delete(name) }
|
|
567
|
+
end
|
|
568
|
+
before != @aliases
|
|
569
|
+
else
|
|
570
|
+
false
|
|
571
|
+
end
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
def sync_runtime_environment(words)
|
|
575
|
+
before = @env.dup
|
|
576
|
+
case words.first
|
|
577
|
+
when "export"
|
|
578
|
+
words.drop(1).reject { |assignment| assignment.start_with?("-") }.each do |assignment|
|
|
579
|
+
key, value = assignment.split("=", 2)
|
|
580
|
+
@env[key] = value.to_s if value && valid_environment_key?(key)
|
|
581
|
+
end
|
|
582
|
+
when "unset"
|
|
583
|
+
words.drop(1).reject { |key| key == "--" || key.start_with?("-") }.each do |key|
|
|
584
|
+
@env.delete(key) if valid_environment_key?(key)
|
|
585
|
+
end
|
|
586
|
+
else
|
|
587
|
+
if words.all? { |word| word.match?(/\A[A-Za-z_][A-Za-z0-9_]*=.*/) }
|
|
588
|
+
words.each do |assignment|
|
|
589
|
+
key, value = assignment.split("=", 2)
|
|
590
|
+
@env[key] = value.to_s if valid_environment_key?(key)
|
|
591
|
+
end
|
|
592
|
+
end
|
|
593
|
+
end
|
|
594
|
+
before != @env
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
def valid_environment_key?(key)
|
|
598
|
+
key.to_s.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
def stateful_command?(words)
|
|
602
|
+
STATEFUL_COMMANDS.include?(words.first) || words.all? { |word| word.match?(/\A[A-Za-z_][A-Za-z0-9_]*=.*/) }
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
def shell_words(command)
|
|
606
|
+
Shellwords.shellsplit(command)
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
def command_echo(command)
|
|
610
|
+
ANSI.sanitize_transcript("$ #{command}\n")
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
def clean_output(output)
|
|
614
|
+
ANSI.sanitize_transcript(output.to_s.gsub("\r\n", "\n").gsub("\r", "\n"))
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
def append_output_newline(output)
|
|
618
|
+
output << "\n" unless output.empty? || output.end_with?("\n")
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
def remember_result(command, result)
|
|
622
|
+
@last_command = command.to_s
|
|
623
|
+
@last_result = result
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
def update_cwd(value)
|
|
627
|
+
path = value.to_s
|
|
628
|
+
return if path.empty? || !File.directory?(path)
|
|
629
|
+
|
|
630
|
+
return if path == @cwd
|
|
631
|
+
|
|
632
|
+
@cwd = File.expand_path(path)
|
|
633
|
+
@completion_shell = nil
|
|
634
|
+
@routing_shell = build_routing_shell
|
|
635
|
+
@env = @routing_shell.child_env
|
|
636
|
+
rescue ArgumentError
|
|
637
|
+
nil
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
def display_cwd
|
|
641
|
+
home = Dir.home.to_s
|
|
642
|
+
return "~" if @cwd == home
|
|
643
|
+
return "~#{@cwd.delete_prefix(home)}" if !home.empty? && @cwd.start_with?("#{home}/")
|
|
644
|
+
|
|
645
|
+
@cwd
|
|
646
|
+
rescue ArgumentError
|
|
647
|
+
@cwd
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
def context_output(output, max_output_bytes)
|
|
651
|
+
text = ANSI.sanitize_transcript(output.to_s)
|
|
652
|
+
limit = max_output_bytes.to_i
|
|
653
|
+
return text if limit <= 0 || text.bytesize <= limit
|
|
654
|
+
|
|
655
|
+
prefix = "[Earlier output omitted; showing the end of the command output.]\n"
|
|
656
|
+
tail_limit = [limit - prefix.bytesize, 0].max
|
|
657
|
+
tail = text.byteslice(-tail_limit, tail_limit).to_s if tail_limit.positive?
|
|
658
|
+
value = "#{prefix}#{tail}"
|
|
659
|
+
ANSI.normalize_transcript_encoding(value.byteslice(-limit, limit).to_s)
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
def next_marker
|
|
663
|
+
@marker_counter += 1
|
|
664
|
+
"#{@marker_prefix}#{@marker_counter}__"
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
def effective_timeout(timeout_seconds)
|
|
668
|
+
timeout = timeout_seconds.to_i
|
|
669
|
+
timeout.positive? ? timeout : @timeout_seconds
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
def write_raw(value)
|
|
673
|
+
@write_mutex.synchronize do
|
|
674
|
+
ensure_open
|
|
675
|
+
@writer.write(value)
|
|
676
|
+
@writer.flush
|
|
677
|
+
end
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
def interrupt_process
|
|
681
|
+
write_raw("\u0003")
|
|
682
|
+
rescue IOError, Errno::EIO
|
|
683
|
+
nil
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
def restart_process
|
|
687
|
+
close
|
|
688
|
+
@closed = false
|
|
689
|
+
start_process
|
|
690
|
+
initialize_protocol
|
|
691
|
+
rescue StandardError
|
|
692
|
+
close
|
|
693
|
+
raise
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
def read_chunk
|
|
697
|
+
chunk = @reader.read_nonblock(READ_SIZE, exception: false)
|
|
698
|
+
return nil if chunk.nil?
|
|
699
|
+
return :wait_readable if chunk == :wait_readable
|
|
700
|
+
|
|
701
|
+
chunk
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
def update_window_size
|
|
705
|
+
return unless @reader && !@reader.closed?
|
|
706
|
+
|
|
707
|
+
rows, columns = @window_size_provider ? @window_size_provider.call : IO.console&.winsize
|
|
708
|
+
rows = LocalPtyCommandRunner::DEFAULT_ROWS unless rows.to_i.positive?
|
|
709
|
+
columns = LocalPtyCommandRunner::DEFAULT_COLUMNS unless columns.to_i.positive?
|
|
710
|
+
size = [rows, columns]
|
|
711
|
+
return if size == @window_size
|
|
712
|
+
|
|
713
|
+
@window_size = size
|
|
714
|
+
@reader.winsize = size
|
|
715
|
+
rescue StandardError
|
|
716
|
+
nil
|
|
717
|
+
end
|
|
718
|
+
|
|
719
|
+
def process_running?(pid)
|
|
720
|
+
Process.kill(0, pid)
|
|
721
|
+
true
|
|
722
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
723
|
+
false
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
def terminate_process_group(pid)
|
|
727
|
+
return unless pid
|
|
728
|
+
|
|
729
|
+
signal_process("TERM", -pid) || signal_process("TERM", pid)
|
|
730
|
+
deadline = Time.now + 0.2
|
|
731
|
+
while Time.now < deadline
|
|
732
|
+
return unless process_running?(pid)
|
|
733
|
+
|
|
734
|
+
sleep 0.02
|
|
735
|
+
end
|
|
736
|
+
signal_process("KILL", -pid) || signal_process("KILL", pid)
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
def signal_process(signal, pid)
|
|
740
|
+
Process.kill(signal, pid)
|
|
741
|
+
true
|
|
742
|
+
rescue Errno::ESRCH, Errno::EINVAL, Errno::EPERM
|
|
743
|
+
false
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
def ensure_open
|
|
747
|
+
raise IOError, "The embedded shell is closed." if @closed || !@reader || @reader.closed?
|
|
748
|
+
end
|
|
749
|
+
end
|
|
750
|
+
end
|