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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -0
  3. data/Gemfile.lock +2 -2
  4. data/doc/agent-tools.md +1 -0
  5. data/doc/composer.md +1 -1
  6. data/doc/configuration.md +18 -3
  7. data/doc/editor.md +20 -10
  8. data/doc/files.md +4 -3
  9. data/doc/permissions.md +1 -0
  10. data/doc/releasing.md +12 -4
  11. data/doc/rpc.md +2 -2
  12. data/doc/sandboxing.md +5 -1
  13. data/doc/security.md +1 -1
  14. data/doc/shell.md +40 -16
  15. data/doc/tabs.md +3 -0
  16. data/doc/usage.md +3 -2
  17. data/lib/kward/agent.rb +1 -0
  18. data/lib/kward/auth/anthropic_oauth.rb +7 -7
  19. data/lib/kward/cli/interactive_turn.rb +101 -19
  20. data/lib/kward/cli/runtime_helpers.rb +156 -14
  21. data/lib/kward/cli/slash_commands.rb +12 -1
  22. data/lib/kward/cli/tabs.rb +12 -5
  23. data/lib/kward/cli.rb +10 -0
  24. data/lib/kward/compaction/token_estimator.rb +12 -6
  25. data/lib/kward/config_files.rb +17 -0
  26. data/lib/kward/editor_prompt.rb +46 -0
  27. data/lib/kward/editor_prompt_session.rb +28 -0
  28. data/lib/kward/ekwsh.rb +70 -10
  29. data/lib/kward/model/client.rb +1 -17
  30. data/lib/kward/model/model_info.rb +3 -2
  31. data/lib/kward/model/payloads.rb +0 -2
  32. data/lib/kward/persistent_shell_session.rb +750 -0
  33. data/lib/kward/project_files.rb +18 -5
  34. data/lib/kward/prompt_interface/composer_renderer.rb +1 -1
  35. data/lib/kward/prompt_interface/editor/auto_indent.rb +3 -0
  36. data/lib/kward/prompt_interface/editor/controller.rb +69 -0
  37. data/lib/kward/prompt_interface/editor/modes/modern.rb +4 -0
  38. data/lib/kward/prompt_interface/editor/modes/vibe.rb +10 -4
  39. data/lib/kward/prompt_interface/editor/renderer.rb +1 -0
  40. data/lib/kward/prompt_interface/editor/state.rb +11 -0
  41. data/lib/kward/prompt_interface/editor/word_completion.rb +124 -0
  42. data/lib/kward/prompt_interface/file_overlay.rb +21 -7
  43. data/lib/kward/prompt_interface/key_handler.rb +8 -0
  44. data/lib/kward/prompt_interface/project_browser.rb +30 -5
  45. data/lib/kward/prompt_interface/runtime_state.rb +5 -1
  46. data/lib/kward/prompt_interface.rb +76 -0
  47. data/lib/kward/prompts/commands.rb +1 -0
  48. data/lib/kward/rpc/auth_manager.rb +1 -1
  49. data/lib/kward/rpc/server.rb +2 -1
  50. data/lib/kward/shell_prompt.rb +50 -0
  51. data/lib/kward/shell_prompt_session.rb +58 -0
  52. data/lib/kward/terminal_keys.rb +1 -0
  53. data/lib/kward/tools/prepare_shell_command.rb +28 -0
  54. data/lib/kward/tools/registry.rb +63 -5
  55. data/lib/kward/tools/replace_editor_buffer.rb +30 -0
  56. data/lib/kward/tools/run_shell_command.rb +24 -6
  57. data/lib/kward/version.rb +1 -1
  58. data/templates/default/layout/html/layout.erb +1 -1
  59. 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