kward 0.72.0 → 0.73.1

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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +30 -0
  3. data/CHANGELOG.md +59 -0
  4. data/Gemfile.lock +2 -2
  5. data/doc/configuration.md +1 -1
  6. data/doc/editor.md +23 -2
  7. data/doc/git.md +1 -0
  8. data/doc/rpc.md +2 -2
  9. data/doc/shell.md +56 -10
  10. data/doc/usage.md +27 -1
  11. data/lib/kward/ansi.rb +62 -23
  12. data/lib/kward/cli/plugins.rb +1 -1
  13. data/lib/kward/cli/rendering.rb +4 -1
  14. data/lib/kward/cli/runtime_helpers.rb +141 -7
  15. data/lib/kward/cli/settings.rb +0 -1
  16. data/lib/kward/cli/slash_commands.rb +213 -0
  17. data/lib/kward/cli/tabs.rb +34 -4
  18. data/lib/kward/cli/tool_summaries.rb +6 -0
  19. data/lib/kward/cli.rb +4 -12
  20. data/lib/kward/clipboard.rb +2 -3
  21. data/lib/kward/compactor.rb +7 -19
  22. data/lib/kward/config_files.rb +26 -4
  23. data/lib/kward/ekwsh.rb +239 -42
  24. data/lib/kward/image_attachments.rb +3 -1
  25. data/lib/kward/interactive_pty_runner.rb +151 -0
  26. data/lib/kward/local_command_runner.rb +155 -0
  27. data/lib/kward/local_pty_command_runner.rb +171 -0
  28. data/lib/kward/model/context_usage.rb +2 -2
  29. data/lib/kward/model/payloads.rb +2 -5
  30. data/lib/kward/prompt_history.rb +5 -3
  31. data/lib/kward/prompt_interface/editor/auto_indent.rb +5 -4
  32. data/lib/kward/prompt_interface/editor/controller.rb +262 -62
  33. data/lib/kward/prompt_interface/editor/modes/emacs.rb +21 -21
  34. data/lib/kward/prompt_interface/editor/modes/modern.rb +38 -37
  35. data/lib/kward/prompt_interface/editor/modes/vibe.rb +23 -173
  36. data/lib/kward/prompt_interface/editor/modes/vibe_insert_readline.rb +166 -0
  37. data/lib/kward/prompt_interface/editor/renderer.rb +6 -5
  38. data/lib/kward/prompt_interface/editor/state.rb +28 -6
  39. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +5 -3
  40. data/lib/kward/prompt_interface/git_prompt.rb +12 -23
  41. data/lib/kward/prompt_interface/interactive/controller.rb +1 -1
  42. data/lib/kward/prompt_interface/key_handler.rb +93 -51
  43. data/lib/kward/prompt_interface/question_prompt.rb +1 -6
  44. data/lib/kward/prompt_interface/screen.rb +3 -3
  45. data/lib/kward/prompt_interface/selection_prompt.rb +12 -6
  46. data/lib/kward/prompt_interface/slash_overlay.rb +2 -0
  47. data/lib/kward/prompt_interface.rb +87 -221
  48. data/lib/kward/prompts/commands.rb +4 -0
  49. data/lib/kward/rpc/memory_methods.rb +83 -0
  50. data/lib/kward/rpc/server.rb +130 -83
  51. data/lib/kward/rpc/session_manager.rb +10 -74
  52. data/lib/kward/rpc/tool_metadata.rb +11 -0
  53. data/lib/kward/rpc/transcript_normalizer.rb +4 -39
  54. data/lib/kward/scratchpad_runner.rb +56 -0
  55. data/lib/kward/session_diff.rb +20 -3
  56. data/lib/kward/session_naming.rb +11 -0
  57. data/lib/kward/terminal_keys.rb +84 -0
  58. data/lib/kward/terminal_sequences.rb +42 -0
  59. data/lib/kward/tools/context_for_task.rb +2 -0
  60. data/lib/kward/version.rb +1 -1
  61. data/lib/kward/workers/git_guard.rb +25 -0
  62. data/lib/kward/workers/job.rb +99 -0
  63. data/lib/kward/workers/queue_runner.rb +166 -0
  64. data/lib/kward/workers/queue_store.rb +112 -0
  65. data/lib/kward/workers.rb +3 -0
  66. data/lib/kward/workspace.rb +15 -63
  67. data/templates/default/fulldoc/html/css/kward.css +33 -0
  68. data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
  69. data/templates/default/fulldoc/html/setup.rb +1 -0
  70. data/templates/default/layout/html/layout.erb +19 -32
  71. metadata +15 -1
@@ -130,8 +130,7 @@ module Kward
130
130
  end
131
131
 
132
132
  def tool_calls(message)
133
- calls = value(message, :tool_calls)
134
- calls.is_a?(Array) ? calls : []
133
+ MessageAccess.tool_calls(message)
135
134
  end
136
135
 
137
136
  def tool_call_name(tool_call)
@@ -335,7 +334,7 @@ module Kward
335
334
 
336
335
  def tool_call_args(tool_call)
337
336
  function = tool_call["function"] || tool_call[:function] || {}
338
- parse_tool_arguments(function["arguments"] || function[:arguments])
337
+ ToolCall.parse_arguments(function["arguments"] || function[:arguments])
339
338
  end
340
339
 
341
340
  def tool_command(tool_call)
@@ -351,15 +350,6 @@ module Kward
351
350
  "#{name}(#{rendered})"
352
351
  end
353
352
  end
354
-
355
- def parse_tool_arguments(arguments)
356
- return {} if arguments.nil? || arguments.empty?
357
- return arguments if arguments.is_a?(Hash)
358
-
359
- JSON.parse(arguments)
360
- rescue JSON::ParserError
361
- {}
362
- end
363
353
  end
364
354
 
365
355
  # Compaction support object used by conversation summarization.
@@ -441,7 +431,7 @@ module Kward
441
431
  end
442
432
 
443
433
  def message_role(message)
444
- message["role"] || message[:role]
434
+ MessageAccess.role(message)
445
435
  end
446
436
  end
447
437
 
@@ -518,22 +508,20 @@ module Kward
518
508
  end
519
509
 
520
510
  def compaction_summary(message)
521
- message["summary"] || message[:summary] || message["content"] || message[:content]
511
+ MessageAccess.summary(message) || MessageAccess.content(message)
522
512
  end
523
513
 
524
514
  def compaction_details(message)
525
- return {} unless message
526
-
527
- details = message["details"] || message[:details]
515
+ details = MessageAccess.value(message, :details)
528
516
  details.is_a?(Hash) ? details : {}
529
517
  end
530
518
 
531
519
  def entry_id(message, index)
532
- message["id"] || message[:id] || "message:#{index}"
520
+ MessageAccess.value(message, :id) || "message:#{index}"
533
521
  end
534
522
 
535
523
  def message_role(message)
536
- message["role"] || message[:role]
524
+ MessageAccess.role(message)
537
525
  end
538
526
  end
539
527
 
@@ -3,6 +3,7 @@ require "fileutils"
3
3
  require "json"
4
4
  require "yaml"
5
5
  require_relative "private_file"
6
+ require_relative "ekwsh"
6
7
  require_relative "editor_mode"
7
8
  require_relative "prompts/templates"
8
9
  require_relative "skills/registry"
@@ -120,9 +121,11 @@ module Kward
120
121
  File.join(cache_dir, "project_browser_state.json")
121
122
  end
122
123
 
123
- def prompt_history_path(cwd, config_dir: self.config_dir)
124
+ def prompt_history_path(cwd, config_dir: self.config_dir, kind: "prompt")
124
125
  key = Digest::SHA256.hexdigest(canonical_workspace_root(cwd))[0, 24]
125
- File.join(config_dir, "history", "#{key}.jsonl")
126
+ return File.join(config_dir, "history", "#{key}.jsonl") if kind.to_s == "prompt"
127
+
128
+ File.join(config_dir, "history", kind.to_s, "#{key}.jsonl")
126
129
  end
127
130
 
128
131
  # @return [String] directory containing structured memory files
@@ -170,7 +173,7 @@ module Kward
170
173
 
171
174
  def read_ekwsh_config(path = ekwsh_config_path)
172
175
  path = File.expand_path(path)
173
- return { env: {}, aliases: {} } unless File.exist?(path)
176
+ return normalize_ekwsh_config(nil) unless File.exist?(path)
174
177
 
175
178
  data = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false)
176
179
  normalize_ekwsh_config(data)
@@ -182,11 +185,30 @@ module Kward
182
185
  data = data.transform_keys(&:to_s) if data.is_a?(Hash)
183
186
  settings = data.is_a?(Hash) ? data : {}
184
187
  {
188
+ shell: normalize_ekwsh_shell(settings["shell"]),
189
+ timeout_seconds: normalize_positive_integer(settings["timeout_seconds"], Ekwsh::DEFAULT_TIMEOUT_SECONDS),
190
+ max_output_bytes: normalize_positive_integer(settings["max_output_bytes"], Ekwsh::DEFAULT_MAX_OUTPUT_BYTES),
191
+ history_limit: normalize_positive_integer(settings["history_limit"], Ekwsh::DEFAULT_HISTORY_LIMIT),
185
192
  env: normalize_ekwsh_env(settings["env"]),
186
193
  aliases: normalize_ekwsh_aliases(settings["aliases"])
187
194
  }
188
195
  end
189
196
 
197
+ def normalize_ekwsh_shell(value)
198
+ shell = value.to_s.strip
199
+ return Ekwsh::DEFAULT_SHELL if shell.empty?
200
+ return shell if shell.start_with?("/") && File.executable?(shell)
201
+
202
+ Ekwsh::DEFAULT_SHELL
203
+ end
204
+
205
+ def normalize_positive_integer(value, default)
206
+ integer = Integer(value)
207
+ integer.positive? ? integer : default
208
+ rescue ArgumentError, TypeError
209
+ default
210
+ end
211
+
190
212
  def normalize_ekwsh_env(values)
191
213
  return {} unless values.is_a?(Hash)
192
214
 
@@ -205,7 +227,7 @@ module Kward
205
227
  values.each_with_object({}) do |(name, command), result|
206
228
  name = name.to_s
207
229
  command = command.to_s.strip
208
- next unless name.match?(/\A[A-Za-z0-9_.:-]+\z/)
230
+ next unless Ekwsh.valid_alias_name?(name)
209
231
  next if command.empty?
210
232
 
211
233
  result[name] = command
data/lib/kward/ekwsh.rb CHANGED
@@ -1,17 +1,37 @@
1
- require "open3"
2
1
  require "shellwords"
2
+ require_relative "ansi"
3
+ require_relative "local_command_runner"
4
+ begin
5
+ require_relative "local_pty_command_runner"
6
+ rescue LoadError
7
+ nil
8
+ end
3
9
 
4
10
  # Namespace for the Kward CLI agent runtime.
5
11
  module Kward
6
12
  # Kward-native embedded shell command runner.
7
13
  class Ekwsh
8
- Result = Struct.new(:output, :exit_status, :exit_shell, :clear, :open_editor_path, keyword_init: true)
14
+ Result = Struct.new(:output, :exit_status, :exit_shell, :clear, :open_editor_path, :interactive_command, :streamed, keyword_init: true)
9
15
  Completion = Struct.new(:range, :replacement, :candidates, keyword_init: true)
10
- BUILTINS = %w[alias cd pwd export unset clear exit logout].freeze
16
+ BUILTINS = %w[alias cd pwd export unset unalias clear exit logout pty].freeze
17
+ DEFAULT_SHELL = "/bin/sh"
18
+ DEFAULT_TIMEOUT_SECONDS = 300
19
+ DEFAULT_MAX_OUTPUT_BYTES = 1_048_576
20
+ DEFAULT_HISTORY_LIMIT = 1_000
11
21
 
12
22
  attr_reader :cwd
13
23
 
14
- def initialize(cwd: Dir.pwd, env: ENV.to_h, shell: ENV["SHELL"], configured_env: {}, aliases: {})
24
+ def command_shell
25
+ @shell
26
+ end
27
+
28
+ def child_env(interactive: false)
29
+ env = @env.dup
30
+ env.delete("GIT_PAGER") if interactive && @defaulted_git_pager
31
+ env
32
+ end
33
+
34
+ def initialize(cwd: Dir.pwd, env: ENV.to_h, shell: DEFAULT_SHELL, configured_env: {}, aliases: {}, timeout_seconds: DEFAULT_TIMEOUT_SECONDS, max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES)
15
35
  @cwd = File.expand_path(cwd.to_s.empty? ? Dir.pwd : cwd.to_s)
16
36
  @previous_cwd = nil
17
37
  @env = env.to_h.transform_keys(&:to_s).transform_values(&:to_s)
@@ -20,29 +40,31 @@ module Kward
20
40
  configure_rbenv_environment
21
41
  configure_color_environment
22
42
  @aliases = aliases.to_h.transform_keys(&:to_s).transform_values(&:to_s)
23
- @shell = shell.to_s.empty? ? "/bin/sh" : shell.to_s
43
+ @shell = shell.to_s.empty? ? DEFAULT_SHELL : shell.to_s
44
+ @timeout_seconds = timeout_seconds.to_i.positive? ? timeout_seconds.to_i : DEFAULT_TIMEOUT_SECONDS
45
+ @max_output_bytes = max_output_bytes.to_i.positive? ? max_output_bytes.to_i : DEFAULT_MAX_OUTPUT_BYTES
24
46
  end
25
47
 
26
48
  def prompt_label
27
49
  "Shell #{display_cwd} $"
28
50
  end
29
51
 
30
- def run(input)
52
+ def run(input, cancellation: nil, &block)
31
53
  command = input.to_s.strip
32
54
  return Result.new(output: "", exit_status: 0) if command.empty?
33
- return Result.new(output: command_echo(command), exit_status: 0, exit_shell: true) if exit_command?(command)
34
55
 
35
- builtin_result(command) || run_expanded_command(command)
56
+ run_expanded_command(command, cancellation: cancellation, &block)
36
57
  end
37
58
 
38
59
  def complete(input, cursor)
39
60
  token = completion_token(input.to_s, cursor.to_i)
40
61
  return nil if token[:command] && token[:text].empty?
41
62
 
42
- candidates = if token[:command] && !path_like_token?(token[:text])
43
- command_candidates(token[:text])
63
+ completion_text = token[:path_text] || token[:text]
64
+ candidates = if token[:command] && !path_like_token?(completion_text) && !token[:quote]
65
+ command_candidates(completion_text)
44
66
  else
45
- path_candidates(token[:text], directories_only: cd_completion?(input, token))
67
+ path_candidates(completion_text, directories_only: cd_completion?(input, token), quote: token[:quote])
46
68
  end
47
69
  return nil if candidates.empty?
48
70
 
@@ -73,16 +95,43 @@ module Kward
73
95
  def configure_color_environment
74
96
  @env["CLICOLOR"] ||= "1"
75
97
  @env["COLORTERM"] ||= "truecolor"
98
+ @defaulted_git_pager = !@env.key?("GIT_PAGER")
99
+ @env["GIT_PAGER"] ||= "cat"
76
100
  @env["TERM"] = "xterm-256color" if @env["TERM"].to_s.empty? || @env["TERM"] == "dumb"
77
101
  end
78
102
 
79
103
  def completion_token(input, cursor)
80
104
  cursor = [[cursor, 0].max, input.length].min
81
- start_index = cursor
105
+ start_index = unmatched_quote_start(input[0...cursor]) || cursor
82
106
  start_index -= 1 while start_index.positive? && token_character?(input, start_index - 1)
83
107
  text = input[start_index...cursor].to_s
84
108
  before = input[0...start_index].to_s
85
- { range: (start_index...cursor), text: text, command: before.strip.empty? }
109
+ quote = quoted_completion_token(text)
110
+ path_text = quote ? text[1..].to_s : nil
111
+ { range: (start_index...cursor), text: text, path_text: path_text, quote: quote, command: before.strip.empty? }
112
+ end
113
+
114
+ def unmatched_quote_start(text)
115
+ quote = nil
116
+ quote_index = nil
117
+ escaped = false
118
+ text.to_s.each_char.with_index do |char, index|
119
+ if escaped
120
+ escaped = false
121
+ next
122
+ end
123
+ if char == "\\" && quote != "'"
124
+ escaped = true
125
+ next
126
+ end
127
+ if quote
128
+ quote = nil if char == quote
129
+ elsif ["'", '"'].include?(char)
130
+ quote = char
131
+ quote_index = index
132
+ end
133
+ end
134
+ quote ? quote_index : nil
86
135
  end
87
136
 
88
137
  def token_character?(input, index)
@@ -109,12 +158,24 @@ module Kward
109
158
  text.to_s.include?("/")
110
159
  end
111
160
 
161
+ def quoted_completion_token(text)
162
+ quote = text.to_s[0]
163
+ return nil unless ["'", '"'].include?(quote)
164
+ return nil if text[1..].to_s.include?(quote)
165
+
166
+ quote
167
+ end
168
+
112
169
  def command_candidates(prefix)
113
170
  (BUILTINS + @aliases.keys + path_executables).uniq.grep(/\A#{Regexp.escape(prefix)}/).sort
114
171
  end
115
172
 
116
173
  def path_executables
117
- @env.fetch("PATH", "").split(File::PATH_SEPARATOR).flat_map do |path|
174
+ path = @env.fetch("PATH", "")
175
+ return @path_executables_cache if @path_executables_cache_path == path && @path_executables_cache
176
+
177
+ @path_executables_cache_path = path
178
+ @path_executables_cache = path.split(File::PATH_SEPARATOR).flat_map do |path|
118
179
  next [] unless File.directory?(path)
119
180
 
120
181
  Dir.children(path).filter_map do |entry|
@@ -126,7 +187,12 @@ module Kward
126
187
  end
127
188
  end
128
189
 
129
- def path_candidates(prefix, directories_only: false)
190
+ def invalidate_path_executables_cache
191
+ @path_executables_cache_path = nil
192
+ @path_executables_cache = nil
193
+ end
194
+
195
+ def path_candidates(prefix, directories_only: false, quote: nil)
130
196
  raw_dir, raw_base = split_path_prefix(prefix)
131
197
  dir = File.expand_path(unescape_path(raw_dir.empty? ? "." : raw_dir), @cwd)
132
198
  return [] unless File.directory?(dir)
@@ -138,7 +204,7 @@ module Kward
138
204
  directory = File.directory?(path)
139
205
  next if directories_only && !directory
140
206
 
141
- completed = "#{raw_dir}#{Shellwords.escape(entry)}"
207
+ completed = path_completion_candidate(raw_dir, entry, quote: quote)
142
208
  completed = "#{completed}/" if directory
143
209
  completed
144
210
  end.sort
@@ -146,6 +212,13 @@ module Kward
146
212
  []
147
213
  end
148
214
 
215
+ def path_completion_candidate(raw_dir, entry, quote: nil)
216
+ completed = "#{raw_dir}#{entry}"
217
+ return "#{quote}#{completed.gsub(quote, "\\#{quote}")}" if quote
218
+
219
+ "#{raw_dir}#{Shellwords.escape(entry)}"
220
+ end
221
+
149
222
  def split_path_prefix(prefix)
150
223
  index = prefix.rindex("/")
151
224
  return ["", prefix] unless index
@@ -187,35 +260,59 @@ module Kward
187
260
  end
188
261
 
189
262
  def command_echo(command)
190
- "$ #{command}\n"
263
+ ANSI.sanitize_transcript("$ #{command}\n")
191
264
  end
192
265
 
193
- def exit_command?(command)
194
- ["exit", "logout"].include?(command)
266
+ def exit_result(command, display_command: command)
267
+ words = shell_words(command)
268
+ return nil unless %w[exit logout].include?(words.first)
269
+
270
+ if words.length > 2 || (words[1] && !words[1].match?(/\A\d+\z/))
271
+ return Result.new(output: "#{command_echo(display_command)}ekwsh: #{words.first}: numeric status expected\n", exit_status: 2)
272
+ end
273
+
274
+ Result.new(output: command_echo(display_command), exit_status: words[1].to_i, exit_shell: true)
275
+ rescue ArgumentError => e
276
+ Result.new(output: "#{command_echo(display_command)}ekwsh: #{e.message}\n", exit_status: 2)
195
277
  end
196
278
 
197
- def builtin_result(command)
279
+ def builtin_result(command, display_command: command)
198
280
  words = shell_words(command)
199
281
  return nil if words.empty?
282
+ assignment_result = persist_assignments(display_command, words)
283
+ return assignment_result if assignment_result
200
284
 
201
285
  case words.first
202
286
  when "alias"
203
- list_aliases(command, words)
287
+ list_aliases(display_command, words)
288
+ when "unalias"
289
+ remove_aliases(display_command, words)
204
290
  when "cd"
205
- change_directory(command, words)
291
+ change_directory(display_command, words)
206
292
  when "pwd"
207
- Result.new(output: "#{command_echo(command)}#{@cwd}\n", exit_status: 0)
293
+ print_working_directory(display_command, words)
208
294
  when "export"
209
- export_variables(command, words)
295
+ export_variables(display_command, words)
210
296
  when "unset"
211
- unset_variables(command, words)
297
+ unset_variables(display_command, words)
212
298
  when "clear"
213
299
  Result.new(output: "", exit_status: 0, clear: true)
300
+ when "pty"
301
+ interactive_pty_result(command, display_command: display_command)
214
302
  else
215
303
  nil
216
304
  end
217
305
  rescue ArgumentError => e
218
- Result.new(output: "#{command_echo(command)}ekwsh: #{e.message}\n", exit_status: 2)
306
+ Result.new(output: "#{command_echo(display_command)}ekwsh: #{e.message}\n", exit_status: 2)
307
+ end
308
+
309
+ def interactive_pty_result(command, display_command: command)
310
+ interactive_command = command.sub(/\A\s*pty(?:\s+|\z)/, "")
311
+ if interactive_command.empty?
312
+ return Result.new(output: "#{command_echo(display_command)}Usage: pty <command>\n", exit_status: 2)
313
+ end
314
+
315
+ Result.new(output: "#{command_echo(display_command)}[interactive PTY session started]\n", exit_status: 0, interactive_command: interactive_command)
219
316
  end
220
317
 
221
318
  def shell_words(command)
@@ -236,12 +333,31 @@ module Kward
236
333
  return Result.new(output: "#{command_echo(command)}ekwsh: alias: invalid name: #{invalid.join(" ")}\n", exit_status: 2) unless invalid.empty?
237
334
 
238
335
  names = @aliases.keys.sort if names.empty? && assignments.empty?
239
- lines = names.filter_map { |name| @aliases[name] ? "#{name}=#{Shellwords.escape(@aliases.fetch(name))}" : nil }
336
+ lines = names.filter_map { |name| @aliases[name] ? "alias #{name}=#{Shellwords.escape(@aliases.fetch(name))}" : nil }
240
337
  suffix = lines.empty? ? "" : "#{lines.join("\n")}\n"
241
338
  Result.new(output: "#{command_echo(command)}#{suffix}", exit_status: 0)
242
339
  end
243
340
 
341
+ def remove_aliases(command, words)
342
+ if words[1] == "-a" && words.length == 2
343
+ @aliases.clear
344
+ return Result.new(output: command_echo(command), exit_status: 0)
345
+ end
346
+ if words.length < 2 || words.drop(1).any? { |word| word.start_with?("-") }
347
+ return Result.new(output: "#{command_echo(command)}Usage: unalias name ... | unalias -a\n", exit_status: 2)
348
+ end
349
+
350
+ missing = words.drop(1).reject { |name| @aliases.delete(name) }
351
+ return Result.new(output: "#{command_echo(command)}ekwsh: unalias: not found: #{missing.join(" ")}\n", exit_status: 1) unless missing.empty?
352
+
353
+ Result.new(output: command_echo(command), exit_status: 0)
354
+ end
355
+
244
356
  def valid_alias_name?(name)
357
+ self.class.valid_alias_name?(name)
358
+ end
359
+
360
+ def self.valid_alias_name?(name)
245
361
  name.to_s.match?(/\A[A-Za-z_][A-Za-z0-9_-]*\z/) && !BUILTINS.include?(name.to_s)
246
362
  end
247
363
 
@@ -256,12 +372,18 @@ module Kward
256
372
  command
257
373
  end
258
374
 
259
- def run_expanded_command(command)
375
+ def run_expanded_command(command, cancellation: nil, &block)
260
376
  expanded_command = expand_alias(command)
377
+ exit_result = exit_result(expanded_command, display_command: command)
378
+ return exit_result if exit_result
379
+
380
+ builtin_result = builtin_result(expanded_command, display_command: command)
381
+ return builtin_result if builtin_result
382
+
261
383
  kward_result = kward_command_result(expanded_command, display_command: command)
262
384
  return kward_result if kward_result
263
385
 
264
- execute(expanded_command, display_command: command)
386
+ execute(expanded_command, display_command: command, cancellation: cancellation, &block)
265
387
  end
266
388
 
267
389
  def kward_command_result(command, display_command: command)
@@ -284,7 +406,34 @@ module Kward
284
406
  File.basename(words[0].to_s) == "kward"
285
407
  end
286
408
 
409
+ def persist_assignments(command, words)
410
+ return nil unless words.all? { |word| assignment_word?(word) }
411
+
412
+ words.each do |assignment|
413
+ key, value = assignment.split("=", 2)
414
+ set_env(key, value.to_s)
415
+ end
416
+ Result.new(output: command_echo(command), exit_status: 0)
417
+ end
418
+
419
+ def assignment_word?(word)
420
+ key, value = word.to_s.split("=", 2)
421
+ !value.nil? && valid_env_key?(key)
422
+ end
423
+
424
+ def print_working_directory(command, words)
425
+ if words.length > 2 || (words[1] && !%w[-L -P].include?(words[1]))
426
+ return Result.new(output: "#{command_echo(command)}Usage: pwd [-L|-P]\n", exit_status: 2)
427
+ end
428
+
429
+ Result.new(output: "#{command_echo(command)}#{@cwd}\n", exit_status: 0)
430
+ end
431
+
287
432
  def change_directory(command, words)
433
+ if words.length > 2
434
+ return Result.new(output: "#{command_echo(command)}ekwsh: cd: too many arguments\n", exit_status: 2)
435
+ end
436
+
288
437
  target = words[1]
289
438
  target = Dir.home if target.nil? || target.empty?
290
439
  target = @previous_cwd || @cwd if target == "-"
@@ -303,7 +452,7 @@ module Kward
303
452
  end
304
453
 
305
454
  def export_variables(command, words)
306
- if words.length == 1
455
+ if words.length == 1 || words == ["export", "-p"]
307
456
  lines = @env.keys.sort.map { |key| "export #{key}=#{Shellwords.escape(@env.fetch(key))}" }
308
457
  return Result.new(output: "#{command_echo(command)}#{lines.join("\n")}\n", exit_status: 0)
309
458
  end
@@ -311,10 +460,10 @@ module Kward
311
460
  invalid = []
312
461
  words.drop(1).each do |assignment|
313
462
  key, value = assignment.split("=", 2)
314
- if value.nil? || !valid_env_key?(key)
463
+ if !valid_env_key?(key) || assignment.start_with?("-")
315
464
  invalid << assignment
316
465
  else
317
- @env[key] = value
466
+ set_env(key, value.nil? ? "" : value)
318
467
  end
319
468
  end
320
469
 
@@ -326,8 +475,10 @@ module Kward
326
475
  end
327
476
 
328
477
  def unset_variables(command, words)
329
- invalid = words.drop(1).reject { |key| valid_env_key?(key) }
330
- words.drop(1).each { |key| @env.delete(key) if valid_env_key?(key) }
478
+ names = words.drop(1)
479
+ names.shift if names.first == "--"
480
+ invalid = names.select { |key| key.start_with?("-") || !valid_env_key?(key) }
481
+ names.each { |key| delete_env(key) if valid_env_key?(key) }
331
482
 
332
483
  if invalid.empty?
333
484
  Result.new(output: command_echo(command), exit_status: 0)
@@ -336,26 +487,72 @@ module Kward
336
487
  end
337
488
  end
338
489
 
490
+ def set_env(key, value)
491
+ @env[key] = value
492
+ invalidate_path_executables_cache if key == "PATH"
493
+ end
494
+
495
+ def delete_env(key)
496
+ @env.delete(key)
497
+ invalidate_path_executables_cache if key == "PATH"
498
+ end
499
+
339
500
  def valid_env_key?(key)
340
501
  key.to_s.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
341
502
  end
342
503
 
343
- def execute(command, display_command: command)
344
- stdout, stderr, status = Open3.capture3(@env, @shell, "-c", command, chdir: @cwd)
345
- exit_status = status.exitstatus || 1
504
+ def execute(command, display_command: command, cancellation: nil)
346
505
  output = command_echo(display_command)
347
- output << clean_output(stdout)
348
- output << clean_output(stderr)
349
- output << "Exit status: #{exit_status}\n" unless exit_status.zero?
350
- Result.new(output: output, exit_status: exit_status)
506
+ streamed = block_given?
507
+ yield output.dup if streamed
508
+ result = external_command_runner.new(
509
+ timeout_seconds: @timeout_seconds,
510
+ max_output_bytes: @max_output_bytes,
511
+ terminate_on_output_limit: true
512
+ ).run(@shell, "-c", command, env: @env, cwd: @cwd, cancellation: cancellation) do |_stream, chunk|
513
+ text = clean_chunk(chunk)
514
+ output << text
515
+ yield text if streamed
516
+ end
517
+ append_output_newline(output) { |text| yield text if streamed }
518
+ exit_status = result.timed_out || result.truncated ? 1 : (result.exit_status || 1)
519
+ append_streamed(output, "ekwsh: command timed out after #{@timeout_seconds} seconds\n", streamed) { |text| yield text } if result.timed_out
520
+ append_streamed(output, "ekwsh: output exceeded #{@max_output_bytes} bytes; command terminated\n", streamed) { |text| yield text } if result.truncated
521
+ append_streamed(output, "Exit status: #{exit_status}\n", streamed) { |text| yield text } unless exit_status.zero?
522
+ Result.new(output: output, exit_status: exit_status, streamed: streamed)
523
+ rescue Cancellation::CancelledError
524
+ append_output_newline(output) { |text| yield text if streamed }
525
+ append_streamed(output, "^C\nExit status: 130\n", streamed) { |text| yield text }
526
+ Result.new(output: output, exit_status: 130, streamed: streamed)
351
527
  rescue Errno::ENOENT => e
352
528
  Result.new(output: "#{command_echo(display_command)}ekwsh: #{e.message}\n", exit_status: 127)
353
529
  end
354
530
 
355
- def clean_output(value)
531
+ def external_command_runner
532
+ defined?(LocalPtyCommandRunner) ? LocalPtyCommandRunner : LocalCommandRunner
533
+ end
534
+
535
+ def append_streamed(output, text, streamed)
536
+ output << text
537
+ yield text if streamed && block_given?
538
+ end
539
+
540
+ def append_output_newline(output)
541
+ return if output.end_with?("\n") || output.empty?
542
+
543
+ output << "\n"
544
+ yield "\n"
545
+ end
546
+
547
+ def clean_chunk(value)
356
548
  text = value.to_s.dup
357
549
  text.force_encoding(Encoding::UTF_8)
358
550
  text = text.valid_encoding? ? text : text.scrub
551
+ ANSI.sanitize_transcript(text)
552
+ end
553
+
554
+ def clean_output(value)
555
+ text = clean_chunk(value)
359
556
  text.end_with?("\n") || text.empty? ? text : "#{text}\n"
360
557
  end
361
558
  end
@@ -47,7 +47,9 @@ module Kward
47
47
  def display_text_without_references(text, references)
48
48
  references.reduce(text.to_s.dup) do |result, reference|
49
49
  source = reference[:source_text].to_s
50
- source.empty? ? result : result.sub(source, "")
50
+ next result if source.empty?
51
+
52
+ result.sub(source, "").sub(Shellwords.escape(source), "")
51
53
  end.gsub(/[ \t]{2,}/, " ").gsub(/[ \t]+\n/, "\n").strip
52
54
  end
53
55