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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +30 -0
- data/CHANGELOG.md +93 -0
- data/Gemfile.lock +2 -2
- data/README.md +4 -0
- data/doc/agent-tools.md +15 -6
- data/doc/authentication.md +22 -1
- data/doc/code-search.md +42 -2
- data/doc/configuration.md +106 -3
- data/doc/context-budgeting.md +136 -0
- data/doc/context-tools.md +16 -3
- data/doc/editor.md +415 -0
- data/doc/extensibility.md +16 -7
- data/doc/files.md +100 -0
- data/doc/getting-started.md +25 -18
- data/doc/git.md +123 -0
- data/doc/memory.md +24 -4
- data/doc/personas.md +34 -5
- data/doc/plugins.md +72 -1
- data/doc/releasing.md +37 -9
- data/doc/rpc.md +75 -5
- data/doc/session-management.md +35 -1
- data/doc/shell.md +332 -0
- data/doc/tabs.md +122 -0
- data/doc/troubleshooting.md +77 -1
- data/doc/usage.md +79 -7
- data/doc/web-search.md +12 -4
- data/doc/workspace-tools.md +51 -12
- data/examples/plugins/space_invaders.rb +377 -0
- data/lib/kward/agent.rb +1 -1
- data/lib/kward/ansi.rb +62 -23
- data/lib/kward/cli/commands.rb +33 -2
- data/lib/kward/cli/git.rb +150 -0
- data/lib/kward/cli/interactive_turn.rb +73 -9
- data/lib/kward/cli/plugins.rb +54 -4
- data/lib/kward/cli/prompt_interface.rb +32 -1
- data/lib/kward/cli/rendering.rb +4 -1
- data/lib/kward/cli/runtime_helpers.rb +268 -4
- data/lib/kward/cli/sessions.rb +2 -2
- data/lib/kward/cli/settings.rb +217 -9
- data/lib/kward/cli/slash_commands.rb +628 -2
- data/lib/kward/cli/tabs.rb +725 -0
- data/lib/kward/cli/tool_summaries.rb +6 -0
- data/lib/kward/cli.rb +150 -26
- data/lib/kward/clipboard.rb +2 -3
- data/lib/kward/compactor.rb +7 -19
- data/lib/kward/config_files.rb +145 -1
- data/lib/kward/context_budget_meter.rb +44 -0
- data/lib/kward/conversation.rb +12 -4
- data/lib/kward/editor_mode.rb +25 -0
- data/lib/kward/ekwsh.rb +559 -0
- data/lib/kward/image_attachments.rb +3 -1
- data/lib/kward/interactive_pty_runner.rb +151 -0
- data/lib/kward/local_command_runner.rb +155 -0
- data/lib/kward/local_pty_command_runner.rb +171 -0
- data/lib/kward/model/context_usage.rb +2 -2
- data/lib/kward/model/payloads.rb +2 -5
- data/lib/kward/plugin_registry.rb +61 -0
- data/lib/kward/project_files.rb +52 -0
- data/lib/kward/prompt_history.rb +84 -0
- data/lib/kward/prompt_interface/composer_controller.rb +69 -1
- data/lib/kward/prompt_interface/composer_renderer.rb +109 -13
- 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 +510 -0
- data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
- data/lib/kward/prompt_interface/editor/controller.rb +1218 -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 +354 -0
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +1812 -0
- data/lib/kward/prompt_interface/editor/modes/vibe_insert_readline.rb +166 -0
- data/lib/kward/prompt_interface/editor/renderer.rb +244 -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 +1271 -0
- data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +422 -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 +288 -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 +451 -57
- 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/question_prompt.rb +99 -56
- data/lib/kward/prompt_interface/runtime_state.rb +43 -0
- data/lib/kward/prompt_interface/screen.rb +19 -3
- data/lib/kward/prompt_interface/selection_prompt.rb +10 -19
- data/lib/kward/prompt_interface/slash_overlay.rb +2 -0
- data/lib/kward/prompt_interface/stream_state.rb +7 -0
- data/lib/kward/prompt_interface/transcript_buffer.rb +6 -0
- data/lib/kward/prompt_interface.rb +366 -222
- data/lib/kward/prompts/commands.rb +9 -0
- data/lib/kward/prompts.rb +2 -0
- data/lib/kward/rpc/memory_methods.rb +83 -0
- data/lib/kward/rpc/server.rb +169 -83
- data/lib/kward/rpc/session_manager.rb +45 -121
- data/lib/kward/rpc/session_tree_rows.rb +9 -115
- data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
- data/lib/kward/rpc/tool_metadata.rb +11 -0
- data/lib/kward/rpc/transcript_normalizer.rb +4 -39
- data/lib/kward/scratchpad_runner.rb +56 -0
- data/lib/kward/session_diff.rb +20 -3
- data/lib/kward/session_naming.rb +11 -0
- data/lib/kward/session_store.rb +44 -0
- 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/terminal_keys.rb +84 -0
- data/lib/kward/terminal_sequences.rb +42 -0
- data/lib/kward/text_boundary.rb +25 -0
- data/lib/kward/tools/context_budget_stats.rb +54 -0
- data/lib/kward/tools/context_for_task.rb +204 -0
- data/lib/kward/tools/read_file.rb +8 -4
- data/lib/kward/tools/registry.rb +62 -16
- data/lib/kward/tools/tool_call.rb +10 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workers/git_guard.rb +93 -0
- data/lib/kward/workers/job.rb +99 -0
- data/lib/kward/workers/live_view.rb +49 -0
- data/lib/kward/workers/manager.rb +288 -0
- data/lib/kward/workers/queue_runner.rb +166 -0
- data/lib/kward/workers/queue_store.rb +112 -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 +10 -0
- data/lib/kward/workspace.rb +125 -87
- data/templates/default/fulldoc/html/css/kward.css +140 -36
- data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
- data/templates/default/fulldoc/html/setup.rb +1 -0
- data/templates/default/kward_navigation.rb +12 -1
- data/templates/default/layout/html/layout.erb +23 -34
- data/templates/default/layout/html/setup.rb +6 -0
- metadata +67 -1
data/lib/kward/conversation.rb
CHANGED
|
@@ -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.
|
|
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
|
data/lib/kward/ekwsh.rb
ADDED
|
@@ -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
|
-
|
|
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
|
|