kward 0.71.0 → 0.73.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 (143) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +30 -0
  3. data/CHANGELOG.md +93 -0
  4. data/Gemfile.lock +2 -2
  5. data/README.md +4 -0
  6. data/doc/agent-tools.md +15 -6
  7. data/doc/authentication.md +22 -1
  8. data/doc/code-search.md +42 -2
  9. data/doc/configuration.md +106 -3
  10. data/doc/context-budgeting.md +136 -0
  11. data/doc/context-tools.md +16 -3
  12. data/doc/editor.md +415 -0
  13. data/doc/extensibility.md +16 -7
  14. data/doc/files.md +100 -0
  15. data/doc/getting-started.md +25 -18
  16. data/doc/git.md +123 -0
  17. data/doc/memory.md +24 -4
  18. data/doc/personas.md +34 -5
  19. data/doc/plugins.md +72 -1
  20. data/doc/releasing.md +37 -9
  21. data/doc/rpc.md +75 -5
  22. data/doc/session-management.md +35 -1
  23. data/doc/shell.md +332 -0
  24. data/doc/tabs.md +122 -0
  25. data/doc/troubleshooting.md +77 -1
  26. data/doc/usage.md +79 -7
  27. data/doc/web-search.md +12 -4
  28. data/doc/workspace-tools.md +51 -12
  29. data/examples/plugins/space_invaders.rb +377 -0
  30. data/lib/kward/agent.rb +1 -1
  31. data/lib/kward/ansi.rb +62 -23
  32. data/lib/kward/cli/commands.rb +33 -2
  33. data/lib/kward/cli/git.rb +150 -0
  34. data/lib/kward/cli/interactive_turn.rb +73 -9
  35. data/lib/kward/cli/plugins.rb +54 -4
  36. data/lib/kward/cli/prompt_interface.rb +32 -1
  37. data/lib/kward/cli/rendering.rb +4 -1
  38. data/lib/kward/cli/runtime_helpers.rb +268 -4
  39. data/lib/kward/cli/sessions.rb +2 -2
  40. data/lib/kward/cli/settings.rb +217 -9
  41. data/lib/kward/cli/slash_commands.rb +628 -2
  42. data/lib/kward/cli/tabs.rb +725 -0
  43. data/lib/kward/cli/tool_summaries.rb +6 -0
  44. data/lib/kward/cli.rb +150 -26
  45. data/lib/kward/clipboard.rb +2 -3
  46. data/lib/kward/compactor.rb +7 -19
  47. data/lib/kward/config_files.rb +145 -1
  48. data/lib/kward/context_budget_meter.rb +44 -0
  49. data/lib/kward/conversation.rb +12 -4
  50. data/lib/kward/editor_mode.rb +25 -0
  51. data/lib/kward/ekwsh.rb +559 -0
  52. data/lib/kward/image_attachments.rb +3 -1
  53. data/lib/kward/interactive_pty_runner.rb +151 -0
  54. data/lib/kward/local_command_runner.rb +155 -0
  55. data/lib/kward/local_pty_command_runner.rb +171 -0
  56. data/lib/kward/model/context_usage.rb +2 -2
  57. data/lib/kward/model/payloads.rb +2 -5
  58. data/lib/kward/plugin_registry.rb +61 -0
  59. data/lib/kward/project_files.rb +52 -0
  60. data/lib/kward/prompt_history.rb +84 -0
  61. data/lib/kward/prompt_interface/composer_controller.rb +69 -1
  62. data/lib/kward/prompt_interface/composer_renderer.rb +109 -13
  63. data/lib/kward/prompt_interface/composer_state.rb +96 -27
  64. data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
  65. data/lib/kward/prompt_interface/editor/auto_indent.rb +510 -0
  66. data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
  67. data/lib/kward/prompt_interface/editor/controller.rb +1218 -0
  68. data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
  69. data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
  70. data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
  71. data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
  72. data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
  73. data/lib/kward/prompt_interface/editor/modes/modern.rb +354 -0
  74. data/lib/kward/prompt_interface/editor/modes/vibe.rb +1812 -0
  75. data/lib/kward/prompt_interface/editor/modes/vibe_insert_readline.rb +166 -0
  76. data/lib/kward/prompt_interface/editor/renderer.rb +244 -0
  77. data/lib/kward/prompt_interface/editor/search.rb +76 -0
  78. data/lib/kward/prompt_interface/editor/selections.rb +120 -0
  79. data/lib/kward/prompt_interface/editor/state.rb +1271 -0
  80. data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
  81. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +422 -0
  82. data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
  83. data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
  84. data/lib/kward/prompt_interface/file_overlay.rb +211 -0
  85. data/lib/kward/prompt_interface/git_prompt.rb +288 -0
  86. data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
  87. data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
  88. data/lib/kward/prompt_interface/interactive/state.rb +62 -0
  89. data/lib/kward/prompt_interface/key_handler.rb +451 -57
  90. data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
  91. data/lib/kward/prompt_interface/project_browser.rb +524 -0
  92. data/lib/kward/prompt_interface/question_prompt.rb +99 -56
  93. data/lib/kward/prompt_interface/runtime_state.rb +43 -0
  94. data/lib/kward/prompt_interface/screen.rb +19 -3
  95. data/lib/kward/prompt_interface/selection_prompt.rb +10 -19
  96. data/lib/kward/prompt_interface/slash_overlay.rb +2 -0
  97. data/lib/kward/prompt_interface/stream_state.rb +7 -0
  98. data/lib/kward/prompt_interface/transcript_buffer.rb +6 -0
  99. data/lib/kward/prompt_interface.rb +366 -222
  100. data/lib/kward/prompts/commands.rb +9 -0
  101. data/lib/kward/prompts.rb +2 -0
  102. data/lib/kward/rpc/memory_methods.rb +83 -0
  103. data/lib/kward/rpc/server.rb +169 -83
  104. data/lib/kward/rpc/session_manager.rb +45 -121
  105. data/lib/kward/rpc/session_tree_rows.rb +9 -115
  106. data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
  107. data/lib/kward/rpc/tool_metadata.rb +11 -0
  108. data/lib/kward/rpc/transcript_normalizer.rb +4 -39
  109. data/lib/kward/scratchpad_runner.rb +56 -0
  110. data/lib/kward/session_diff.rb +20 -3
  111. data/lib/kward/session_naming.rb +11 -0
  112. data/lib/kward/session_store.rb +44 -0
  113. data/lib/kward/session_tree_nodes.rb +136 -0
  114. data/lib/kward/session_tree_renderer.rb +9 -131
  115. data/lib/kward/tab_store.rb +47 -0
  116. data/lib/kward/terminal_keys.rb +84 -0
  117. data/lib/kward/terminal_sequences.rb +42 -0
  118. data/lib/kward/text_boundary.rb +25 -0
  119. data/lib/kward/tools/context_budget_stats.rb +54 -0
  120. data/lib/kward/tools/context_for_task.rb +204 -0
  121. data/lib/kward/tools/read_file.rb +8 -4
  122. data/lib/kward/tools/registry.rb +62 -16
  123. data/lib/kward/tools/tool_call.rb +10 -0
  124. data/lib/kward/version.rb +1 -1
  125. data/lib/kward/workers/git_guard.rb +93 -0
  126. data/lib/kward/workers/job.rb +99 -0
  127. data/lib/kward/workers/live_view.rb +49 -0
  128. data/lib/kward/workers/manager.rb +288 -0
  129. data/lib/kward/workers/queue_runner.rb +166 -0
  130. data/lib/kward/workers/queue_store.rb +112 -0
  131. data/lib/kward/workers/store.rb +72 -0
  132. data/lib/kward/workers/tool_policy.rb +23 -0
  133. data/lib/kward/workers/worker.rb +82 -0
  134. data/lib/kward/workers/write_lock.rb +38 -0
  135. data/lib/kward/workers.rb +10 -0
  136. data/lib/kward/workspace.rb +125 -87
  137. data/templates/default/fulldoc/html/css/kward.css +140 -36
  138. data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
  139. data/templates/default/fulldoc/html/setup.rb +1 -0
  140. data/templates/default/kward_navigation.rb +12 -1
  141. data/templates/default/layout/html/layout.erb +23 -34
  142. data/templates/default/layout/html/setup.rb +6 -0
  143. metadata +67 -1
@@ -1,5 +1,6 @@
1
1
  require "digest"
2
2
  require "set"
3
+ require_relative "context_budget_meter"
3
4
  require_relative "image_attachments"
4
5
  require_relative "message_access"
5
6
  require_relative "plugin_registry"
@@ -60,6 +61,8 @@ module Kward
60
61
  attr_reader :last_plugin_prompt_context
61
62
  # @return [Hash] original large tool outputs retained outside model context
62
63
  attr_reader :tool_output_artifacts
64
+ # @return [ContextBudgetMeter] runtime context savings for this conversation
65
+ attr_reader :context_budget_meter
63
66
 
64
67
  def initialize(system_message: DEFAULT_SYSTEM_MESSAGE, messages: [], read_paths: [], on_append: nil, on_compact: nil, on_tool_execution: nil, on_runtime_update: nil, workspace_root: Dir.pwd, compaction_system_message: DEFAULT_SYSTEM_MESSAGE, provider: nil, model: nil, reasoning_effort: nil, memory_context: nil, session_memories: [], last_memory_retrieval: nil, plugin_registry: nil)
65
68
  @workspace_root = ConfigFiles.canonical_workspace_root(workspace_root)
@@ -89,6 +92,7 @@ module Kward
89
92
  @session_memories = Array(session_memories)
90
93
  @last_memory_retrieval = last_memory_retrieval
91
94
  @tool_output_artifacts = {}
95
+ @context_budget_meter = ContextBudgetMeter.new
92
96
  @messages.concat(transcript_messages)
93
97
  @read_paths = Set.new(read_paths)
94
98
  @on_append = on_append
@@ -146,6 +150,10 @@ module Kward
146
150
  end
147
151
 
148
152
  def store_tool_output_artifact(tool_name:, content:)
153
+ restore_tool_output_artifact(tool_name: tool_name, content: content, created_at: Time.now.utc)
154
+ end
155
+
156
+ def restore_tool_output_artifact(tool_name:, content:, created_at: nil)
149
157
  text = self.class.normalize_tool_content(content)
150
158
  id = tool_output_artifact_id_for(tool_name: tool_name, content: text)
151
159
  @tool_output_artifacts[id] = {
@@ -153,7 +161,7 @@ module Kward
153
161
  tool_name: tool_name,
154
162
  content: text,
155
163
  bytes: text.bytesize,
156
- created_at: Time.now.utc
164
+ created_at: created_at || Time.now.utc
157
165
  }
158
166
  id
159
167
  end
@@ -186,11 +194,11 @@ module Kward
186
194
  replacement
187
195
  end
188
196
 
189
- def update_runtime_context!(provider: nil, model:, reasoning_effort:)
197
+ def update_runtime_context!(provider: nil, model:, reasoning_effort:, refresh: true)
190
198
  @provider = provider unless provider.to_s.empty?
191
199
  @model = model
192
200
  @reasoning_effort = reasoning_effort
193
- refresh_system_message!
201
+ refresh_system_message! if refresh
194
202
  end
195
203
 
196
204
  def persist_runtime_context!
@@ -270,7 +278,7 @@ module Kward
270
278
  end
271
279
 
272
280
  def prompt_time
273
- Time.at(0)
281
+ Time.now
274
282
  end
275
283
 
276
284
  def workspace_agents_mtime
@@ -0,0 +1,25 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Normalizes built-in TUI file editor mode names.
4
+ module EditorMode
5
+ MODES = %w[modern emacs vibe].freeze
6
+ DEFAULT = "modern".freeze
7
+ LINE_NUMBER_MODES = %w[absolute relative].freeze
8
+ DEFAULT_LINE_NUMBERS = "absolute".freeze
9
+
10
+ module_function
11
+
12
+ def normalize(value)
13
+ text = value.to_s.downcase
14
+ return DEFAULT if text == "default"
15
+ return "vibe" if text == "vi"
16
+
17
+ MODES.include?(text) ? text : DEFAULT
18
+ end
19
+
20
+ def normalize_line_numbers(value)
21
+ text = value.to_s.downcase
22
+ LINE_NUMBER_MODES.include?(text) ? text : DEFAULT_LINE_NUMBERS
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,559 @@
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
9
+
10
+ # Namespace for the Kward CLI agent runtime.
11
+ module Kward
12
+ # Kward-native embedded shell command runner.
13
+ class Ekwsh
14
+ Result = Struct.new(:output, :exit_status, :exit_shell, :clear, :open_editor_path, :interactive_command, :streamed, keyword_init: true)
15
+ Completion = Struct.new(:range, :replacement, :candidates, keyword_init: true)
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
21
+
22
+ attr_reader :cwd
23
+
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)
35
+ @cwd = File.expand_path(cwd.to_s.empty? ? Dir.pwd : cwd.to_s)
36
+ @previous_cwd = nil
37
+ @env = env.to_h.transform_keys(&:to_s).transform_values(&:to_s)
38
+ @env.merge!(configured_env.to_h.transform_keys(&:to_s).transform_values(&:to_s))
39
+ @env["PWD"] = @cwd
40
+ configure_rbenv_environment
41
+ configure_color_environment
42
+ @aliases = aliases.to_h.transform_keys(&:to_s).transform_values(&: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
46
+ end
47
+
48
+ def prompt_label
49
+ "Shell #{display_cwd} $"
50
+ end
51
+
52
+ def run(input, cancellation: nil, &block)
53
+ command = input.to_s.strip
54
+ return Result.new(output: "", exit_status: 0) if command.empty?
55
+
56
+ run_expanded_command(command, cancellation: cancellation, &block)
57
+ end
58
+
59
+ def complete(input, cursor)
60
+ token = completion_token(input.to_s, cursor.to_i)
61
+ return nil if token[:command] && token[:text].empty?
62
+
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)
66
+ else
67
+ path_candidates(completion_text, directories_only: cd_completion?(input, token), quote: token[:quote])
68
+ end
69
+ return nil if candidates.empty?
70
+
71
+ replacement = completion_replacement(token[:text], candidates)
72
+ Completion.new(range: token[:range], replacement: replacement, candidates: candidates)
73
+ end
74
+
75
+ private
76
+
77
+ def configure_rbenv_environment
78
+ root = @env["RBENV_ROOT"].to_s
79
+ root = File.expand_path("~/.rbenv") if root.empty?
80
+ root = File.expand_path(root)
81
+ paths = [File.join(root, "shims"), File.join(root, "bin")].select { |path| Dir.exist?(path) }
82
+ return if paths.empty?
83
+
84
+ @env["RBENV_ROOT"] = root
85
+ @env["PATH"] = prepend_path_entries(@env["PATH"], paths)
86
+ rescue ArgumentError
87
+ nil
88
+ end
89
+
90
+ def prepend_path_entries(path, entries)
91
+ current = path.to_s.split(File::PATH_SEPARATOR)
92
+ (entries + current).uniq.join(File::PATH_SEPARATOR)
93
+ end
94
+
95
+ def configure_color_environment
96
+ @env["CLICOLOR"] ||= "1"
97
+ @env["COLORTERM"] ||= "truecolor"
98
+ @defaulted_git_pager = !@env.key?("GIT_PAGER")
99
+ @env["GIT_PAGER"] ||= "cat"
100
+ @env["TERM"] = "xterm-256color" if @env["TERM"].to_s.empty? || @env["TERM"] == "dumb"
101
+ end
102
+
103
+ def completion_token(input, cursor)
104
+ cursor = [[cursor, 0].max, input.length].min
105
+ start_index = unmatched_quote_start(input[0...cursor]) || cursor
106
+ start_index -= 1 while start_index.positive? && token_character?(input, start_index - 1)
107
+ text = input[start_index...cursor].to_s
108
+ before = input[0...start_index].to_s
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
135
+ end
136
+
137
+ def token_character?(input, index)
138
+ return true unless input[index].match?(/\s/)
139
+
140
+ escaped_character?(input, index)
141
+ end
142
+
143
+ def escaped_character?(input, index)
144
+ backslashes = 0
145
+ cursor = index - 1
146
+ while cursor >= 0 && input[cursor] == "\\"
147
+ backslashes += 1
148
+ cursor -= 1
149
+ end
150
+ backslashes.odd?
151
+ end
152
+
153
+ def cd_completion?(input, token)
154
+ input[0...token[:range].begin].to_s.strip == "cd"
155
+ end
156
+
157
+ def path_like_token?(text)
158
+ text.to_s.include?("/")
159
+ end
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
+
169
+ def command_candidates(prefix)
170
+ (BUILTINS + @aliases.keys + path_executables).uniq.grep(/\A#{Regexp.escape(prefix)}/).sort
171
+ end
172
+
173
+ def path_executables
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|
179
+ next [] unless File.directory?(path)
180
+
181
+ Dir.children(path).filter_map do |entry|
182
+ full_path = File.join(path, entry)
183
+ entry if File.file?(full_path) && File.executable?(full_path)
184
+ end
185
+ rescue SystemCallError
186
+ []
187
+ end
188
+ end
189
+
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)
196
+ raw_dir, raw_base = split_path_prefix(prefix)
197
+ dir = File.expand_path(unescape_path(raw_dir.empty? ? "." : raw_dir), @cwd)
198
+ return [] unless File.directory?(dir)
199
+
200
+ Dir.children(dir).filter_map do |entry|
201
+ next unless entry.start_with?(unescape_path(raw_base))
202
+
203
+ path = File.join(dir, entry)
204
+ directory = File.directory?(path)
205
+ next if directories_only && !directory
206
+
207
+ completed = path_completion_candidate(raw_dir, entry, quote: quote)
208
+ completed = "#{completed}/" if directory
209
+ completed
210
+ end.sort
211
+ rescue SystemCallError
212
+ []
213
+ end
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
+
222
+ def split_path_prefix(prefix)
223
+ index = prefix.rindex("/")
224
+ return ["", prefix] unless index
225
+
226
+ [prefix[0..index], prefix[(index + 1)..].to_s]
227
+ end
228
+
229
+ def unescape_path(value)
230
+ value.to_s.gsub(/\\(.)/, "\\1")
231
+ end
232
+
233
+ def completion_replacement(prefix, candidates)
234
+ return add_completion_suffix(candidates.first) if candidates.length == 1
235
+
236
+ common = common_prefix(candidates)
237
+ common.length > prefix.length ? common : prefix
238
+ end
239
+
240
+ def add_completion_suffix(candidate)
241
+ candidate.end_with?("/") ? candidate : "#{candidate} "
242
+ end
243
+
244
+ def common_prefix(values)
245
+ first = values.first.to_s
246
+ values.drop(1).reduce(first) do |prefix, value|
247
+ prefix = prefix[0...-1] until value.start_with?(prefix) || prefix.empty?
248
+ prefix
249
+ end
250
+ end
251
+
252
+ def display_cwd
253
+ home = Dir.home.to_s
254
+ return "~" if @cwd == home
255
+ return "~#{@cwd.delete_prefix(home)}" if !home.empty? && @cwd.start_with?("#{home}/")
256
+
257
+ @cwd
258
+ rescue ArgumentError
259
+ @cwd
260
+ end
261
+
262
+ def command_echo(command)
263
+ ANSI.sanitize_transcript("$ #{command}\n")
264
+ end
265
+
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)
277
+ end
278
+
279
+ def builtin_result(command, display_command: command)
280
+ words = shell_words(command)
281
+ return nil if words.empty?
282
+ assignment_result = persist_assignments(display_command, words)
283
+ return assignment_result if assignment_result
284
+
285
+ case words.first
286
+ when "alias"
287
+ list_aliases(display_command, words)
288
+ when "unalias"
289
+ remove_aliases(display_command, words)
290
+ when "cd"
291
+ change_directory(display_command, words)
292
+ when "pwd"
293
+ print_working_directory(display_command, words)
294
+ when "export"
295
+ export_variables(display_command, words)
296
+ when "unset"
297
+ unset_variables(display_command, words)
298
+ when "clear"
299
+ Result.new(output: "", exit_status: 0, clear: true)
300
+ when "pty"
301
+ interactive_pty_result(command, display_command: display_command)
302
+ else
303
+ nil
304
+ end
305
+ rescue ArgumentError => e
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)
316
+ end
317
+
318
+ def shell_words(command)
319
+ Shellwords.shellsplit(command)
320
+ end
321
+
322
+ def list_aliases(command, words)
323
+ assignments, names = words.drop(1).partition { |word| word.include?("=") }
324
+ invalid = []
325
+ assignments.each do |assignment|
326
+ name, value = assignment.split("=", 2)
327
+ if valid_alias_name?(name)
328
+ @aliases[name] = value.to_s
329
+ else
330
+ invalid << name
331
+ end
332
+ end
333
+ return Result.new(output: "#{command_echo(command)}ekwsh: alias: invalid name: #{invalid.join(" ")}\n", exit_status: 2) unless invalid.empty?
334
+
335
+ names = @aliases.keys.sort if names.empty? && assignments.empty?
336
+ lines = names.filter_map { |name| @aliases[name] ? "alias #{name}=#{Shellwords.escape(@aliases.fetch(name))}" : nil }
337
+ suffix = lines.empty? ? "" : "#{lines.join("\n")}\n"
338
+ Result.new(output: "#{command_echo(command)}#{suffix}", exit_status: 0)
339
+ end
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
+
356
+ def valid_alias_name?(name)
357
+ self.class.valid_alias_name?(name)
358
+ end
359
+
360
+ def self.valid_alias_name?(name)
361
+ name.to_s.match?(/\A[A-Za-z_][A-Za-z0-9_-]*\z/) && !BUILTINS.include?(name.to_s)
362
+ end
363
+
364
+ def expand_alias(command)
365
+ words = shell_words(command)
366
+ return command if words.empty? || BUILTINS.include?(words.first)
367
+ return command unless @aliases[words.first]
368
+
369
+ rest = command.sub(/\A\s*#{Regexp.escape(words.first)}\b\s*/, "")
370
+ [@aliases.fetch(words.first), rest].reject(&:empty?).join(" ")
371
+ rescue ArgumentError
372
+ command
373
+ end
374
+
375
+ def run_expanded_command(command, cancellation: nil, &block)
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
+
383
+ kward_result = kward_command_result(expanded_command, display_command: command)
384
+ return kward_result if kward_result
385
+
386
+ execute(expanded_command, display_command: command, cancellation: cancellation, &block)
387
+ end
388
+
389
+ def kward_command_result(command, display_command: command)
390
+ words = shell_words(command)
391
+ return nil unless kward_edit_command?(words)
392
+
393
+ unless words.length == 3
394
+ return Result.new(output: "#{command_echo(display_command)}Usage: kward edit <filename>\n", exit_status: 2)
395
+ end
396
+
397
+ path = File.expand_path(words[2], @cwd)
398
+ Result.new(output: command_echo(display_command), exit_status: 0, open_editor_path: path)
399
+ rescue ArgumentError => e
400
+ Result.new(output: "#{command_echo(display_command)}ekwsh: #{e.message}\n", exit_status: 2)
401
+ end
402
+
403
+ def kward_edit_command?(words)
404
+ return false unless words[1] == "edit"
405
+
406
+ File.basename(words[0].to_s) == "kward"
407
+ end
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
+
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
+
437
+ target = words[1]
438
+ target = Dir.home if target.nil? || target.empty?
439
+ target = @previous_cwd || @cwd if target == "-"
440
+ path = File.expand_path(target, @cwd)
441
+ unless File.directory?(path)
442
+ return Result.new(output: "#{command_echo(command)}ekwsh: cd: no such directory: #{target}\n", exit_status: 1)
443
+ end
444
+
445
+ @previous_cwd = @cwd
446
+ @cwd = path
447
+ @env["OLDPWD"] = @previous_cwd
448
+ @env["PWD"] = @cwd
449
+ output = command_echo(command)
450
+ output << "#{@cwd}\n" if words[1] == "-"
451
+ Result.new(output: output, exit_status: 0)
452
+ end
453
+
454
+ def export_variables(command, words)
455
+ if words.length == 1 || words == ["export", "-p"]
456
+ lines = @env.keys.sort.map { |key| "export #{key}=#{Shellwords.escape(@env.fetch(key))}" }
457
+ return Result.new(output: "#{command_echo(command)}#{lines.join("\n")}\n", exit_status: 0)
458
+ end
459
+
460
+ invalid = []
461
+ words.drop(1).each do |assignment|
462
+ key, value = assignment.split("=", 2)
463
+ if !valid_env_key?(key) || assignment.start_with?("-")
464
+ invalid << assignment
465
+ else
466
+ set_env(key, value.nil? ? "" : value)
467
+ end
468
+ end
469
+
470
+ if invalid.empty?
471
+ Result.new(output: command_echo(command), exit_status: 0)
472
+ else
473
+ Result.new(output: "#{command_echo(command)}ekwsh: export: invalid assignment: #{invalid.join(" ")}\n", exit_status: 2)
474
+ end
475
+ end
476
+
477
+ def unset_variables(command, words)
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) }
482
+
483
+ if invalid.empty?
484
+ Result.new(output: command_echo(command), exit_status: 0)
485
+ else
486
+ Result.new(output: "#{command_echo(command)}ekwsh: unset: invalid name: #{invalid.join(" ")}\n", exit_status: 2)
487
+ end
488
+ end
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
+
500
+ def valid_env_key?(key)
501
+ key.to_s.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
502
+ end
503
+
504
+ def execute(command, display_command: command, cancellation: nil)
505
+ output = command_echo(display_command)
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)
527
+ rescue Errno::ENOENT => e
528
+ Result.new(output: "#{command_echo(display_command)}ekwsh: #{e.message}\n", exit_status: 127)
529
+ end
530
+
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)
548
+ text = value.to_s.dup
549
+ text.force_encoding(Encoding::UTF_8)
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)
556
+ text.end_with?("\n") || text.empty? ? text : "#{text}\n"
557
+ end
558
+ end
559
+ 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