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