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.
- checksums.yaml +4 -4
- data/.github/workflows/pages.yml +1 -1
- data/CHANGELOG.md +89 -3
- data/Gemfile +2 -0
- data/Gemfile.lock +90 -2
- data/README.md +34 -6
- data/Rakefile +96 -0
- data/doc/agent-tools.md +52 -0
- data/doc/api.md +92 -0
- data/doc/authentication.md +58 -23
- data/doc/code-search.md +42 -2
- data/doc/configuration.md +102 -13
- data/doc/context-budgeting.md +136 -0
- data/doc/context-tools.md +83 -0
- data/doc/editor.md +394 -0
- data/doc/extensibility.md +16 -7
- data/doc/files.md +100 -0
- data/doc/getting-started.md +25 -18
- data/doc/git.md +122 -0
- data/doc/memory.md +24 -4
- data/doc/personas.md +34 -5
- data/doc/plugins.md +74 -3
- data/doc/releasing.md +45 -8
- data/doc/rpc.md +77 -15
- data/doc/session-management.md +254 -0
- data/doc/shell.md +286 -0
- data/doc/tabs.md +122 -0
- data/doc/troubleshooting.md +77 -1
- data/doc/usage.md +60 -15
- data/doc/web-search.md +12 -4
- data/doc/workspace-tools.md +144 -0
- data/examples/plugins/space_invaders.rb +377 -0
- data/lib/kward/agent.rb +1 -1
- data/lib/kward/cli/commands.rb +41 -2
- data/lib/kward/cli/git.rb +150 -0
- data/lib/kward/cli/interactive_turn.rb +73 -9
- data/lib/kward/cli/openrouter_commands.rb +55 -0
- data/lib/kward/cli/plugins.rb +54 -4
- data/lib/kward/cli/prompt_interface.rb +111 -6
- data/lib/kward/cli/rendering.rb +11 -6
- data/lib/kward/cli/runtime_helpers.rb +133 -3
- data/lib/kward/cli/sessions.rb +262 -13
- data/lib/kward/cli/settings.rb +216 -37
- data/lib/kward/cli/slash_commands.rb +439 -8
- data/lib/kward/cli/tabs.rb +695 -0
- data/lib/kward/cli.rb +171 -26
- data/lib/kward/compactor.rb +4 -1
- data/lib/kward/config_files.rb +125 -5
- data/lib/kward/context_budget_meter.rb +44 -0
- data/lib/kward/conversation.rb +59 -22
- data/lib/kward/editor_mode.rb +25 -0
- data/lib/kward/ekwsh.rb +362 -0
- data/lib/kward/model/client.rb +37 -50
- data/lib/kward/model/context_usage.rb +13 -6
- data/lib/kward/model/model_info.rb +92 -16
- data/lib/kward/model/payloads.rb +2 -0
- data/lib/kward/openrouter_model_cache.rb +120 -0
- data/lib/kward/plugin_registry.rb +108 -1
- data/lib/kward/project_files.rb +52 -0
- data/lib/kward/prompt_history.rb +82 -0
- data/lib/kward/prompt_interface/banner.rb +16 -51
- data/lib/kward/prompt_interface/composer_controller.rb +124 -83
- data/lib/kward/prompt_interface/composer_renderer.rb +116 -14
- data/lib/kward/prompt_interface/composer_state.rb +96 -27
- data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
- data/lib/kward/prompt_interface/editor/auto_indent.rb +509 -0
- data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
- data/lib/kward/prompt_interface/editor/controller.rb +1018 -0
- data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
- data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
- data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
- data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
- data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
- data/lib/kward/prompt_interface/editor/modes/modern.rb +353 -0
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +1962 -0
- data/lib/kward/prompt_interface/editor/renderer.rb +243 -0
- data/lib/kward/prompt_interface/editor/search.rb +76 -0
- data/lib/kward/prompt_interface/editor/selections.rb +120 -0
- data/lib/kward/prompt_interface/editor/state.rb +1249 -0
- data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +420 -0
- data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
- data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
- data/lib/kward/prompt_interface/file_overlay.rb +211 -0
- data/lib/kward/prompt_interface/git_prompt.rb +299 -0
- data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
- data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
- data/lib/kward/prompt_interface/interactive/state.rb +62 -0
- data/lib/kward/prompt_interface/key_handler.rb +416 -43
- data/lib/kward/prompt_interface/layout.rb +2 -2
- data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
- data/lib/kward/prompt_interface/project_browser.rb +524 -0
- data/lib/kward/prompt_interface/prompt_renderer.rb +32 -13
- data/lib/kward/prompt_interface/question_prompt.rb +122 -82
- data/lib/kward/prompt_interface/runtime_state.rb +49 -1
- data/lib/kward/prompt_interface/screen.rb +17 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +511 -58
- data/lib/kward/prompt_interface/stream_state.rb +7 -0
- data/lib/kward/prompt_interface/transcript_buffer.rb +13 -16
- data/lib/kward/prompt_interface/transcript_renderer.rb +3 -3
- data/lib/kward/prompt_interface.rb +307 -35
- data/lib/kward/prompts/commands.rb +7 -1
- data/lib/kward/prompts.rb +4 -2
- data/lib/kward/rpc/server.rb +45 -11
- data/lib/kward/rpc/session_manager.rb +52 -53
- data/lib/kward/rpc/session_tree_rows.rb +9 -115
- data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
- data/lib/kward/session_store.rb +67 -4
- data/lib/kward/session_tree_nodes.rb +136 -0
- data/lib/kward/session_tree_renderer.rb +9 -131
- data/lib/kward/tab_store.rb +47 -0
- data/lib/kward/telemetry/logger.rb +5 -3
- data/lib/kward/text_boundary.rb +25 -0
- data/lib/kward/tool_output_compactor.rb +127 -0
- data/lib/kward/tools/base.rb +8 -2
- data/lib/kward/tools/context_budget_stats.rb +54 -0
- data/lib/kward/tools/context_for_task.rb +202 -0
- data/lib/kward/tools/read_file.rb +8 -4
- data/lib/kward/tools/registry.rb +92 -15
- data/lib/kward/tools/retrieve_tool_output.rb +71 -0
- data/lib/kward/tools/search/web.rb +2 -2
- data/lib/kward/tools/summarize_file_structure.rb +29 -0
- data/lib/kward/tools/tool_call.rb +12 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workers/git_guard.rb +68 -0
- data/lib/kward/workers/live_view.rb +49 -0
- data/lib/kward/workers/manager.rb +288 -0
- data/lib/kward/workers/store.rb +72 -0
- data/lib/kward/workers/tool_policy.rb +23 -0
- data/lib/kward/workers/worker.rb +82 -0
- data/lib/kward/workers/write_lock.rb +38 -0
- data/lib/kward/workers.rb +7 -0
- data/lib/kward/workspace.rb +154 -12
- data/templates/default/fulldoc/html/css/kward.css +362 -42
- data/templates/default/fulldoc/html/full_list.erb +107 -0
- data/templates/default/fulldoc/html/js/kward.js +161 -2
- data/templates/default/fulldoc/html/setup.rb +8 -0
- data/templates/default/kward_navigation.rb +102 -0
- data/templates/default/layout/html/layout.erb +43 -10
- data/templates/default/layout/html/setup.rb +39 -38
- metadata +65 -3
- data/lib/kward/resources/avatar_kward_logo.rb +0 -50
- data/lib/kward/resources/pixel_logo.rb +0 -232
data/lib/kward/conversation.rb
CHANGED
|
@@ -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
|
data/lib/kward/ekwsh.rb
ADDED
|
@@ -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
|