kward 0.70.0 → 0.72.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/pages.yml +1 -1
  3. data/CHANGELOG.md +89 -3
  4. data/Gemfile +2 -0
  5. data/Gemfile.lock +90 -2
  6. data/README.md +34 -6
  7. data/Rakefile +96 -0
  8. data/doc/agent-tools.md +52 -0
  9. data/doc/api.md +92 -0
  10. data/doc/authentication.md +58 -23
  11. data/doc/code-search.md +42 -2
  12. data/doc/configuration.md +102 -13
  13. data/doc/context-budgeting.md +136 -0
  14. data/doc/context-tools.md +83 -0
  15. data/doc/editor.md +394 -0
  16. data/doc/extensibility.md +16 -7
  17. data/doc/files.md +100 -0
  18. data/doc/getting-started.md +25 -18
  19. data/doc/git.md +122 -0
  20. data/doc/memory.md +24 -4
  21. data/doc/personas.md +34 -5
  22. data/doc/plugins.md +74 -3
  23. data/doc/releasing.md +45 -8
  24. data/doc/rpc.md +77 -15
  25. data/doc/session-management.md +254 -0
  26. data/doc/shell.md +286 -0
  27. data/doc/tabs.md +122 -0
  28. data/doc/troubleshooting.md +77 -1
  29. data/doc/usage.md +60 -15
  30. data/doc/web-search.md +12 -4
  31. data/doc/workspace-tools.md +144 -0
  32. data/examples/plugins/space_invaders.rb +377 -0
  33. data/lib/kward/agent.rb +1 -1
  34. data/lib/kward/cli/commands.rb +41 -2
  35. data/lib/kward/cli/git.rb +150 -0
  36. data/lib/kward/cli/interactive_turn.rb +73 -9
  37. data/lib/kward/cli/openrouter_commands.rb +55 -0
  38. data/lib/kward/cli/plugins.rb +54 -4
  39. data/lib/kward/cli/prompt_interface.rb +111 -6
  40. data/lib/kward/cli/rendering.rb +11 -6
  41. data/lib/kward/cli/runtime_helpers.rb +133 -3
  42. data/lib/kward/cli/sessions.rb +262 -13
  43. data/lib/kward/cli/settings.rb +216 -37
  44. data/lib/kward/cli/slash_commands.rb +439 -8
  45. data/lib/kward/cli/tabs.rb +695 -0
  46. data/lib/kward/cli.rb +171 -26
  47. data/lib/kward/compactor.rb +4 -1
  48. data/lib/kward/config_files.rb +125 -5
  49. data/lib/kward/context_budget_meter.rb +44 -0
  50. data/lib/kward/conversation.rb +59 -22
  51. data/lib/kward/editor_mode.rb +25 -0
  52. data/lib/kward/ekwsh.rb +362 -0
  53. data/lib/kward/model/client.rb +37 -50
  54. data/lib/kward/model/context_usage.rb +13 -6
  55. data/lib/kward/model/model_info.rb +92 -16
  56. data/lib/kward/model/payloads.rb +2 -0
  57. data/lib/kward/openrouter_model_cache.rb +120 -0
  58. data/lib/kward/plugin_registry.rb +108 -1
  59. data/lib/kward/project_files.rb +52 -0
  60. data/lib/kward/prompt_history.rb +82 -0
  61. data/lib/kward/prompt_interface/banner.rb +16 -51
  62. data/lib/kward/prompt_interface/composer_controller.rb +124 -83
  63. data/lib/kward/prompt_interface/composer_renderer.rb +116 -14
  64. data/lib/kward/prompt_interface/composer_state.rb +96 -27
  65. data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
  66. data/lib/kward/prompt_interface/editor/auto_indent.rb +509 -0
  67. data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
  68. data/lib/kward/prompt_interface/editor/controller.rb +1018 -0
  69. data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
  70. data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
  71. data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
  72. data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
  73. data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
  74. data/lib/kward/prompt_interface/editor/modes/modern.rb +353 -0
  75. data/lib/kward/prompt_interface/editor/modes/vibe.rb +1962 -0
  76. data/lib/kward/prompt_interface/editor/renderer.rb +243 -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 +1249 -0
  80. data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
  81. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +420 -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 +299 -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 +416 -43
  90. data/lib/kward/prompt_interface/layout.rb +2 -2
  91. data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
  92. data/lib/kward/prompt_interface/project_browser.rb +524 -0
  93. data/lib/kward/prompt_interface/prompt_renderer.rb +32 -13
  94. data/lib/kward/prompt_interface/question_prompt.rb +122 -82
  95. data/lib/kward/prompt_interface/runtime_state.rb +49 -1
  96. data/lib/kward/prompt_interface/screen.rb +17 -0
  97. data/lib/kward/prompt_interface/selection_prompt.rb +511 -58
  98. data/lib/kward/prompt_interface/stream_state.rb +7 -0
  99. data/lib/kward/prompt_interface/transcript_buffer.rb +13 -16
  100. data/lib/kward/prompt_interface/transcript_renderer.rb +3 -3
  101. data/lib/kward/prompt_interface.rb +307 -35
  102. data/lib/kward/prompts/commands.rb +7 -1
  103. data/lib/kward/prompts.rb +4 -2
  104. data/lib/kward/rpc/server.rb +45 -11
  105. data/lib/kward/rpc/session_manager.rb +52 -53
  106. data/lib/kward/rpc/session_tree_rows.rb +9 -115
  107. data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
  108. data/lib/kward/session_store.rb +67 -4
  109. data/lib/kward/session_tree_nodes.rb +136 -0
  110. data/lib/kward/session_tree_renderer.rb +9 -131
  111. data/lib/kward/tab_store.rb +47 -0
  112. data/lib/kward/telemetry/logger.rb +5 -3
  113. data/lib/kward/text_boundary.rb +25 -0
  114. data/lib/kward/tool_output_compactor.rb +127 -0
  115. data/lib/kward/tools/base.rb +8 -2
  116. data/lib/kward/tools/context_budget_stats.rb +54 -0
  117. data/lib/kward/tools/context_for_task.rb +202 -0
  118. data/lib/kward/tools/read_file.rb +8 -4
  119. data/lib/kward/tools/registry.rb +92 -15
  120. data/lib/kward/tools/retrieve_tool_output.rb +71 -0
  121. data/lib/kward/tools/search/web.rb +2 -2
  122. data/lib/kward/tools/summarize_file_structure.rb +29 -0
  123. data/lib/kward/tools/tool_call.rb +12 -0
  124. data/lib/kward/version.rb +1 -1
  125. data/lib/kward/workers/git_guard.rb +68 -0
  126. data/lib/kward/workers/live_view.rb +49 -0
  127. data/lib/kward/workers/manager.rb +288 -0
  128. data/lib/kward/workers/store.rb +72 -0
  129. data/lib/kward/workers/tool_policy.rb +23 -0
  130. data/lib/kward/workers/worker.rb +82 -0
  131. data/lib/kward/workers/write_lock.rb +38 -0
  132. data/lib/kward/workers.rb +7 -0
  133. data/lib/kward/workspace.rb +154 -12
  134. data/templates/default/fulldoc/html/css/kward.css +362 -42
  135. data/templates/default/fulldoc/html/full_list.erb +107 -0
  136. data/templates/default/fulldoc/html/js/kward.js +161 -2
  137. data/templates/default/fulldoc/html/setup.rb +8 -0
  138. data/templates/default/kward_navigation.rb +102 -0
  139. data/templates/default/layout/html/layout.erb +43 -10
  140. data/templates/default/layout/html/setup.rb +39 -38
  141. metadata +65 -3
  142. data/lib/kward/resources/avatar_kward_logo.rb +0 -50
  143. data/lib/kward/resources/pixel_logo.rb +0 -232
@@ -1,4 +1,6 @@
1
+ require "digest"
1
2
  require "set"
3
+ require_relative "context_budget_meter"
2
4
  require_relative "image_attachments"
3
5
  require_relative "message_access"
4
6
  require_relative "plugin_registry"
@@ -57,6 +59,10 @@ module Kward
57
59
  attr_accessor :plugin_registry
58
60
  # @return [String, nil] plugin prompt context used in the current system prompt
59
61
  attr_reader :last_plugin_prompt_context
62
+ # @return [Hash] original large tool outputs retained outside model context
63
+ attr_reader :tool_output_artifacts
64
+ # @return [ContextBudgetMeter] runtime context savings for this conversation
65
+ attr_reader :context_budget_meter
60
66
 
61
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)
62
68
  @workspace_root = ConfigFiles.canonical_workspace_root(workspace_root)
@@ -71,13 +77,13 @@ module Kward
71
77
  system_message = restored_system_message
72
78
  else
73
79
  @last_plugin_prompt_context = plugin_prompt_context
74
- system_message = Prompts.system_message(workspace_root: @workspace_root, model: @model, reasoning_effort: @reasoning_effort, memory_context: memory_context, plugin_context: @last_plugin_prompt_context)
80
+ system_message = Prompts.system_message(workspace_root: @workspace_root, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, memory_context: memory_context, plugin_context: @last_plugin_prompt_context)
75
81
  end
76
82
  end
77
83
  @system_message = system_message
78
84
  @system_message_enabled = !@system_message.nil?
79
85
  if compaction_system_message.equal?(DEFAULT_SYSTEM_MESSAGE)
80
- compaction_system_message = @system_message_enabled ? Prompts.system_message(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort) : nil
86
+ compaction_system_message = @system_message_enabled ? Prompts.system_message(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time) : nil
81
87
  end
82
88
  @compaction_system_message = compaction_system_message
83
89
  @workspace_agents_mtime = workspace_agents_mtime
@@ -85,6 +91,8 @@ module Kward
85
91
  @memory_context = memory_context
86
92
  @session_memories = Array(session_memories)
87
93
  @last_memory_retrieval = last_memory_retrieval
94
+ @tool_output_artifacts = {}
95
+ @context_budget_meter = ContextBudgetMeter.new
88
96
  @messages.concat(transcript_messages)
89
97
  @read_paths = Set.new(read_paths)
90
98
  @on_append = on_append
@@ -111,19 +119,58 @@ module Kward
111
119
  end
112
120
 
113
121
  def append_tool(tool_call_id:, name:, content:)
114
- content = normalize_tool_content(content) if content.is_a?(String)
115
122
  append_message({
116
123
  role: "tool",
117
124
  tool_call_id: tool_call_id,
118
125
  name: name,
119
- content: content
126
+ content: self.class.normalize_tool_content(content)
120
127
  })
121
128
  end
122
129
 
130
+ # Tool results may arrive as ASCII-8BIT (BINARY) strings, e.g. from
131
+ # Net::HTTP response bodies or shell command output. When such a string
132
+ # is later concatenated with a UTF-8 string containing non-ASCII bytes
133
+ # (during compaction or JSON serialization), Ruby raises
134
+ # Encoding::CompatibilityError. Re-tag BINARY strings as UTF-8 when the
135
+ # bytes are valid UTF-8; otherwise scrub so the content is always
136
+ # serializable and concatenable.
137
+ def self.normalize_tool_content(content)
138
+ return content unless content.is_a?(String) && content.encoding == Encoding::ASCII_8BIT
139
+
140
+ probe = content.dup.force_encoding(Encoding::UTF_8)
141
+ probe.valid_encoding? ? probe : probe.scrub
142
+ end
143
+
123
144
  def append_tool_execution(tool_call:, content:)
124
145
  @on_tool_execution&.call(tool_call, content)
125
146
  end
126
147
 
148
+ def tool_output_artifact_id_for(tool_name:, content:)
149
+ self.class.tool_output_artifact_id(tool_name: tool_name, content: self.class.normalize_tool_content(content))
150
+ end
151
+
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)
157
+ text = self.class.normalize_tool_content(content)
158
+ id = tool_output_artifact_id_for(tool_name: tool_name, content: text)
159
+ @tool_output_artifacts[id] = {
160
+ id: id,
161
+ tool_name: tool_name,
162
+ content: text,
163
+ bytes: text.bytesize,
164
+ created_at: created_at || Time.now.utc
165
+ }
166
+ id
167
+ end
168
+
169
+ def self.tool_output_artifact_id(tool_name:, content:)
170
+ digest = Digest::SHA256.hexdigest("#{tool_name}\0#{content}")[0, 16]
171
+ "toolout_#{digest}"
172
+ end
173
+
127
174
  # @return [Array<Hash>] provider request context: current system prompt plus durable transcript
128
175
  def context_messages
129
176
  @system_message ? [@system_message] + @messages : @messages.dup
@@ -139,19 +186,19 @@ module Kward
139
186
  return nil unless @system_message_enabled
140
187
 
141
188
  @last_plugin_prompt_context = plugin_prompt_context
142
- replacement = Prompts.system_message(workspace_root: @workspace_root, model: @model, reasoning_effort: @reasoning_effort, memory_context: @memory_context, plugin_context: @last_plugin_prompt_context)
189
+ replacement = Prompts.system_message(workspace_root: @workspace_root, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time, memory_context: @memory_context, plugin_context: @last_plugin_prompt_context)
143
190
  @system_message = replacement
144
191
  @on_system_message_change&.call(replacement)
145
- @compaction_system_message = Prompts.system_message(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort)
192
+ @compaction_system_message = Prompts.system_message(workspace_root: @workspace_root, include_workspace_personality: false, model: @model, reasoning_effort: @reasoning_effort, now: prompt_time)
146
193
  @workspace_agents_mtime = workspace_agents_mtime
147
194
  replacement
148
195
  end
149
196
 
150
- def update_runtime_context!(provider: nil, model:, reasoning_effort:)
197
+ def update_runtime_context!(provider: nil, model:, reasoning_effort:, refresh: true)
151
198
  @provider = provider unless provider.to_s.empty?
152
199
  @model = model
153
200
  @reasoning_effort = reasoning_effort
154
- refresh_system_message!
201
+ refresh_system_message! if refresh
155
202
  end
156
203
 
157
204
  def persist_runtime_context!
@@ -230,6 +277,10 @@ module Kward
230
277
  [system_message, transcript_messages]
231
278
  end
232
279
 
280
+ def prompt_time
281
+ Time.now
282
+ end
283
+
233
284
  def workspace_agents_mtime
234
285
  path = File.join(@workspace_root, "AGENTS.md")
235
286
  File.exist?(path) ? File.mtime(path) : nil
@@ -242,19 +293,5 @@ module Kward
242
293
  message
243
294
  end
244
295
 
245
- # Tool results may arrive as ASCII-8BIT (BINARY) strings, e.g. from
246
- # Net::HTTP response bodies or shell command output. When such a string
247
- # is later concatenated with a UTF-8 string containing non-ASCII bytes
248
- # (during compaction or JSON serialization), Ruby raises
249
- # Encoding::CompatibilityError. Re-tag BINARY strings as UTF-8 when the
250
- # bytes are valid UTF-8; otherwise scrub so the content is always
251
- # serializable and concatenable.
252
- def normalize_tool_content(string)
253
- return string unless string.encoding == Encoding::ASCII_8BIT
254
-
255
- probe = string.dup.force_encoding(Encoding::UTF_8)
256
- probe.valid_encoding? ? probe : probe.scrub
257
- end
258
-
259
296
  end
260
297
  end
@@ -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,362 @@
1
+ require "open3"
2
+ require "shellwords"
3
+
4
+ # Namespace for the Kward CLI agent runtime.
5
+ module Kward
6
+ # Kward-native embedded shell command runner.
7
+ class Ekwsh
8
+ Result = Struct.new(:output, :exit_status, :exit_shell, :clear, :open_editor_path, keyword_init: true)
9
+ Completion = Struct.new(:range, :replacement, :candidates, keyword_init: true)
10
+ BUILTINS = %w[alias cd pwd export unset clear exit logout].freeze
11
+
12
+ attr_reader :cwd
13
+
14
+ def initialize(cwd: Dir.pwd, env: ENV.to_h, shell: ENV["SHELL"], configured_env: {}, aliases: {})
15
+ @cwd = File.expand_path(cwd.to_s.empty? ? Dir.pwd : cwd.to_s)
16
+ @previous_cwd = nil
17
+ @env = env.to_h.transform_keys(&:to_s).transform_values(&:to_s)
18
+ @env.merge!(configured_env.to_h.transform_keys(&:to_s).transform_values(&:to_s))
19
+ @env["PWD"] = @cwd
20
+ configure_rbenv_environment
21
+ configure_color_environment
22
+ @aliases = aliases.to_h.transform_keys(&:to_s).transform_values(&:to_s)
23
+ @shell = shell.to_s.empty? ? "/bin/sh" : shell.to_s
24
+ end
25
+
26
+ def prompt_label
27
+ "Shell #{display_cwd} $"
28
+ end
29
+
30
+ def run(input)
31
+ command = input.to_s.strip
32
+ 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
+
35
+ builtin_result(command) || run_expanded_command(command)
36
+ end
37
+
38
+ def complete(input, cursor)
39
+ token = completion_token(input.to_s, cursor.to_i)
40
+ return nil if token[:command] && token[:text].empty?
41
+
42
+ candidates = if token[:command] && !path_like_token?(token[:text])
43
+ command_candidates(token[:text])
44
+ else
45
+ path_candidates(token[:text], directories_only: cd_completion?(input, token))
46
+ end
47
+ return nil if candidates.empty?
48
+
49
+ replacement = completion_replacement(token[:text], candidates)
50
+ Completion.new(range: token[:range], replacement: replacement, candidates: candidates)
51
+ end
52
+
53
+ private
54
+
55
+ def configure_rbenv_environment
56
+ root = @env["RBENV_ROOT"].to_s
57
+ root = File.expand_path("~/.rbenv") if root.empty?
58
+ root = File.expand_path(root)
59
+ paths = [File.join(root, "shims"), File.join(root, "bin")].select { |path| Dir.exist?(path) }
60
+ return if paths.empty?
61
+
62
+ @env["RBENV_ROOT"] = root
63
+ @env["PATH"] = prepend_path_entries(@env["PATH"], paths)
64
+ rescue ArgumentError
65
+ nil
66
+ end
67
+
68
+ def prepend_path_entries(path, entries)
69
+ current = path.to_s.split(File::PATH_SEPARATOR)
70
+ (entries + current).uniq.join(File::PATH_SEPARATOR)
71
+ end
72
+
73
+ def configure_color_environment
74
+ @env["CLICOLOR"] ||= "1"
75
+ @env["COLORTERM"] ||= "truecolor"
76
+ @env["TERM"] = "xterm-256color" if @env["TERM"].to_s.empty? || @env["TERM"] == "dumb"
77
+ end
78
+
79
+ def completion_token(input, cursor)
80
+ cursor = [[cursor, 0].max, input.length].min
81
+ start_index = cursor
82
+ start_index -= 1 while start_index.positive? && token_character?(input, start_index - 1)
83
+ text = input[start_index...cursor].to_s
84
+ before = input[0...start_index].to_s
85
+ { range: (start_index...cursor), text: text, command: before.strip.empty? }
86
+ end
87
+
88
+ def token_character?(input, index)
89
+ return true unless input[index].match?(/\s/)
90
+
91
+ escaped_character?(input, index)
92
+ end
93
+
94
+ def escaped_character?(input, index)
95
+ backslashes = 0
96
+ cursor = index - 1
97
+ while cursor >= 0 && input[cursor] == "\\"
98
+ backslashes += 1
99
+ cursor -= 1
100
+ end
101
+ backslashes.odd?
102
+ end
103
+
104
+ def cd_completion?(input, token)
105
+ input[0...token[:range].begin].to_s.strip == "cd"
106
+ end
107
+
108
+ def path_like_token?(text)
109
+ text.to_s.include?("/")
110
+ end
111
+
112
+ def command_candidates(prefix)
113
+ (BUILTINS + @aliases.keys + path_executables).uniq.grep(/\A#{Regexp.escape(prefix)}/).sort
114
+ end
115
+
116
+ def path_executables
117
+ @env.fetch("PATH", "").split(File::PATH_SEPARATOR).flat_map do |path|
118
+ next [] unless File.directory?(path)
119
+
120
+ Dir.children(path).filter_map do |entry|
121
+ full_path = File.join(path, entry)
122
+ entry if File.file?(full_path) && File.executable?(full_path)
123
+ end
124
+ rescue SystemCallError
125
+ []
126
+ end
127
+ end
128
+
129
+ def path_candidates(prefix, directories_only: false)
130
+ raw_dir, raw_base = split_path_prefix(prefix)
131
+ dir = File.expand_path(unescape_path(raw_dir.empty? ? "." : raw_dir), @cwd)
132
+ return [] unless File.directory?(dir)
133
+
134
+ Dir.children(dir).filter_map do |entry|
135
+ next unless entry.start_with?(unescape_path(raw_base))
136
+
137
+ path = File.join(dir, entry)
138
+ directory = File.directory?(path)
139
+ next if directories_only && !directory
140
+
141
+ completed = "#{raw_dir}#{Shellwords.escape(entry)}"
142
+ completed = "#{completed}/" if directory
143
+ completed
144
+ end.sort
145
+ rescue SystemCallError
146
+ []
147
+ end
148
+
149
+ def split_path_prefix(prefix)
150
+ index = prefix.rindex("/")
151
+ return ["", prefix] unless index
152
+
153
+ [prefix[0..index], prefix[(index + 1)..].to_s]
154
+ end
155
+
156
+ def unescape_path(value)
157
+ value.to_s.gsub(/\\(.)/, "\\1")
158
+ end
159
+
160
+ def completion_replacement(prefix, candidates)
161
+ return add_completion_suffix(candidates.first) if candidates.length == 1
162
+
163
+ common = common_prefix(candidates)
164
+ common.length > prefix.length ? common : prefix
165
+ end
166
+
167
+ def add_completion_suffix(candidate)
168
+ candidate.end_with?("/") ? candidate : "#{candidate} "
169
+ end
170
+
171
+ def common_prefix(values)
172
+ first = values.first.to_s
173
+ values.drop(1).reduce(first) do |prefix, value|
174
+ prefix = prefix[0...-1] until value.start_with?(prefix) || prefix.empty?
175
+ prefix
176
+ end
177
+ end
178
+
179
+ def display_cwd
180
+ home = Dir.home.to_s
181
+ return "~" if @cwd == home
182
+ return "~#{@cwd.delete_prefix(home)}" if !home.empty? && @cwd.start_with?("#{home}/")
183
+
184
+ @cwd
185
+ rescue ArgumentError
186
+ @cwd
187
+ end
188
+
189
+ def command_echo(command)
190
+ "$ #{command}\n"
191
+ end
192
+
193
+ def exit_command?(command)
194
+ ["exit", "logout"].include?(command)
195
+ end
196
+
197
+ def builtin_result(command)
198
+ words = shell_words(command)
199
+ return nil if words.empty?
200
+
201
+ case words.first
202
+ when "alias"
203
+ list_aliases(command, words)
204
+ when "cd"
205
+ change_directory(command, words)
206
+ when "pwd"
207
+ Result.new(output: "#{command_echo(command)}#{@cwd}\n", exit_status: 0)
208
+ when "export"
209
+ export_variables(command, words)
210
+ when "unset"
211
+ unset_variables(command, words)
212
+ when "clear"
213
+ Result.new(output: "", exit_status: 0, clear: true)
214
+ else
215
+ nil
216
+ end
217
+ rescue ArgumentError => e
218
+ Result.new(output: "#{command_echo(command)}ekwsh: #{e.message}\n", exit_status: 2)
219
+ end
220
+
221
+ def shell_words(command)
222
+ Shellwords.shellsplit(command)
223
+ end
224
+
225
+ def list_aliases(command, words)
226
+ assignments, names = words.drop(1).partition { |word| word.include?("=") }
227
+ invalid = []
228
+ assignments.each do |assignment|
229
+ name, value = assignment.split("=", 2)
230
+ if valid_alias_name?(name)
231
+ @aliases[name] = value.to_s
232
+ else
233
+ invalid << name
234
+ end
235
+ end
236
+ return Result.new(output: "#{command_echo(command)}ekwsh: alias: invalid name: #{invalid.join(" ")}\n", exit_status: 2) unless invalid.empty?
237
+
238
+ names = @aliases.keys.sort if names.empty? && assignments.empty?
239
+ lines = names.filter_map { |name| @aliases[name] ? "#{name}=#{Shellwords.escape(@aliases.fetch(name))}" : nil }
240
+ suffix = lines.empty? ? "" : "#{lines.join("\n")}\n"
241
+ Result.new(output: "#{command_echo(command)}#{suffix}", exit_status: 0)
242
+ end
243
+
244
+ def valid_alias_name?(name)
245
+ name.to_s.match?(/\A[A-Za-z_][A-Za-z0-9_-]*\z/) && !BUILTINS.include?(name.to_s)
246
+ end
247
+
248
+ def expand_alias(command)
249
+ words = shell_words(command)
250
+ return command if words.empty? || BUILTINS.include?(words.first)
251
+ return command unless @aliases[words.first]
252
+
253
+ rest = command.sub(/\A\s*#{Regexp.escape(words.first)}\b\s*/, "")
254
+ [@aliases.fetch(words.first), rest].reject(&:empty?).join(" ")
255
+ rescue ArgumentError
256
+ command
257
+ end
258
+
259
+ def run_expanded_command(command)
260
+ expanded_command = expand_alias(command)
261
+ kward_result = kward_command_result(expanded_command, display_command: command)
262
+ return kward_result if kward_result
263
+
264
+ execute(expanded_command, display_command: command)
265
+ end
266
+
267
+ def kward_command_result(command, display_command: command)
268
+ words = shell_words(command)
269
+ return nil unless kward_edit_command?(words)
270
+
271
+ unless words.length == 3
272
+ return Result.new(output: "#{command_echo(display_command)}Usage: kward edit <filename>\n", exit_status: 2)
273
+ end
274
+
275
+ path = File.expand_path(words[2], @cwd)
276
+ Result.new(output: command_echo(display_command), exit_status: 0, open_editor_path: path)
277
+ rescue ArgumentError => e
278
+ Result.new(output: "#{command_echo(display_command)}ekwsh: #{e.message}\n", exit_status: 2)
279
+ end
280
+
281
+ def kward_edit_command?(words)
282
+ return false unless words[1] == "edit"
283
+
284
+ File.basename(words[0].to_s) == "kward"
285
+ end
286
+
287
+ def change_directory(command, words)
288
+ target = words[1]
289
+ target = Dir.home if target.nil? || target.empty?
290
+ target = @previous_cwd || @cwd if target == "-"
291
+ path = File.expand_path(target, @cwd)
292
+ unless File.directory?(path)
293
+ return Result.new(output: "#{command_echo(command)}ekwsh: cd: no such directory: #{target}\n", exit_status: 1)
294
+ end
295
+
296
+ @previous_cwd = @cwd
297
+ @cwd = path
298
+ @env["OLDPWD"] = @previous_cwd
299
+ @env["PWD"] = @cwd
300
+ output = command_echo(command)
301
+ output << "#{@cwd}\n" if words[1] == "-"
302
+ Result.new(output: output, exit_status: 0)
303
+ end
304
+
305
+ def export_variables(command, words)
306
+ if words.length == 1
307
+ lines = @env.keys.sort.map { |key| "export #{key}=#{Shellwords.escape(@env.fetch(key))}" }
308
+ return Result.new(output: "#{command_echo(command)}#{lines.join("\n")}\n", exit_status: 0)
309
+ end
310
+
311
+ invalid = []
312
+ words.drop(1).each do |assignment|
313
+ key, value = assignment.split("=", 2)
314
+ if value.nil? || !valid_env_key?(key)
315
+ invalid << assignment
316
+ else
317
+ @env[key] = value
318
+ end
319
+ end
320
+
321
+ if invalid.empty?
322
+ Result.new(output: command_echo(command), exit_status: 0)
323
+ else
324
+ Result.new(output: "#{command_echo(command)}ekwsh: export: invalid assignment: #{invalid.join(" ")}\n", exit_status: 2)
325
+ end
326
+ end
327
+
328
+ 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) }
331
+
332
+ if invalid.empty?
333
+ Result.new(output: command_echo(command), exit_status: 0)
334
+ else
335
+ Result.new(output: "#{command_echo(command)}ekwsh: unset: invalid name: #{invalid.join(" ")}\n", exit_status: 2)
336
+ end
337
+ end
338
+
339
+ def valid_env_key?(key)
340
+ key.to_s.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
341
+ end
342
+
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
346
+ 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)
351
+ rescue Errno::ENOENT => e
352
+ Result.new(output: "#{command_echo(display_command)}ekwsh: #{e.message}\n", exit_status: 127)
353
+ end
354
+
355
+ def clean_output(value)
356
+ text = value.to_s.dup
357
+ text.force_encoding(Encoding::UTF_8)
358
+ text = text.valid_encoding? ? text : text.scrub
359
+ text.end_with?("\n") || text.empty? ? text : "#{text}\n"
360
+ end
361
+ end
362
+ end