kward 0.83.0 → 0.84.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/CHANGELOG.md +68 -15
- data/CONTRIBUTING.md +74 -0
- data/Gemfile.lock +8 -2
- data/README.md +21 -1
- data/Rakefile +46 -2
- data/SECURITY.md +31 -0
- data/doc/api.md +4 -0
- data/doc/composer.md +1 -1
- data/doc/configuration.md +68 -21
- data/doc/editor.md +28 -13
- data/doc/files.md +8 -4
- data/doc/getting-started.md +3 -0
- data/doc/pan.md +19 -15
- data/doc/platform-support.md +48 -0
- data/doc/security.md +3 -2
- data/doc/shell.md +62 -45
- data/doc/troubleshooting.md +12 -2
- data/doc/usage.md +5 -5
- data/kward.gemspec +5 -4
- data/lib/kward/agent.rb +6 -3
- data/lib/kward/ansi.rb +110 -10
- data/lib/kward/cli/auth_commands.rb +34 -13
- data/lib/kward/cli/commands.rb +83 -62
- data/lib/kward/cli/doctor.rb +39 -17
- data/lib/kward/cli/hook_commands.rb +22 -12
- data/lib/kward/cli/interactive_turn.rb +48 -7
- data/lib/kward/cli/project_skills_commands.rb +8 -4
- data/lib/kward/cli/prompt_interface.rb +27 -0
- data/lib/kward/cli/rendering.rb +15 -9
- data/lib/kward/cli/runtime_helpers.rb +118 -55
- data/lib/kward/cli/slash_commands.rb +12 -14
- data/lib/kward/cli/tabs.rb +83 -12
- data/lib/kward/cli/tool_summaries.rb +14 -0
- data/lib/kward/cli.rb +45 -7
- data/lib/kward/cli_transcript_formatter.rb +11 -4
- data/lib/kward/config_files.rb +82 -68
- data/lib/kward/detached_run.rb +44 -0
- data/lib/kward/interactive_pty_runner.rb +102 -28
- data/lib/kward/{ekwsh.rb → kwsh.rb} +35 -16
- data/lib/kward/kwshrc.rb +233 -0
- data/lib/kward/markdown_code_block.rb +136 -0
- data/lib/kward/model/client.rb +34 -22
- data/lib/kward/model/provider_catalog.rb +5 -0
- data/lib/kward/model/stream_parser.rb +20 -4
- data/lib/kward/pan/index.html.erb +3 -3
- data/lib/kward/pan/server.rb +23 -3
- data/lib/kward/persistent_shell_session.rb +119 -26
- data/lib/kward/project_files.rb +2 -2
- data/lib/kward/prompt_interface/composer_renderer.rb +44 -40
- data/lib/kward/prompt_interface/composer_state.rb +33 -24
- data/lib/kward/prompt_interface/editor/auto_indent.rb +24 -22
- data/lib/kward/prompt_interface/editor/controller.rb +30 -33
- data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +289 -44
- data/lib/kward/prompt_interface/editor/renderer.rb +108 -6
- data/lib/kward/prompt_interface/editor/runner.rb +362 -0
- data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
- data/lib/kward/prompt_interface/editor/state.rb +10 -10
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
- data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
- data/lib/kward/prompt_interface/file_overlay.rb +71 -15
- data/lib/kward/prompt_interface/key_handler.rb +67 -0
- data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
- data/lib/kward/prompt_interface/project_browser.rb +415 -14
- data/lib/kward/prompt_interface/runtime_state.rb +50 -1
- data/lib/kward/prompt_interface/screen.rb +2 -2
- data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
- data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
- data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
- data/lib/kward/prompt_interface.rb +93 -18
- data/lib/kward/prompts/commands.rb +1 -1
- data/lib/kward/prompts.rb +1 -1
- data/lib/kward/pty_output_sink.rb +47 -0
- data/lib/kward/rpc/transcript_normalizer.rb +7 -3
- data/lib/kward/scratchpad_languages.rb +74 -0
- data/lib/kward/scratchpad_runner.rb +155 -29
- data/lib/kward/shell_prompt.rb +2 -0
- data/lib/kward/terminal_keys.rb +12 -0
- data/lib/kward/terminal_text.rb +121 -0
- data/lib/kward/text_matcher.rb +18 -0
- data/lib/kward/tools/open_editor.rb +41 -0
- data/lib/kward/tools/registry.rb +13 -4
- data/lib/kward/tools/tool_call.rb +2 -1
- data/lib/kward/version.rb +1 -1
- data/templates/default/fulldoc/html/css/kward.css +0 -125
- data/templates/default/fulldoc/html/images/kward_workflow.svg +52 -0
- data/templates/default/fulldoc/html/setup.rb +1 -1
- data/templates/default/kward_navigation.rb +1 -0
- data/templates/default/layout/html/footer.erb +10 -0
- data/templates/default/layout/html/headers.erb +23 -0
- data/templates/default/layout/html/layout.erb +6 -18
- data/templates/default/layout/html/setup.rb +41 -2
- metadata +36 -8
- data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
|
@@ -3,6 +3,7 @@ require "pty"
|
|
|
3
3
|
require "timeout"
|
|
4
4
|
require_relative "local_pty_command_runner"
|
|
5
5
|
require_relative "pty_output_sink"
|
|
6
|
+
require_relative "detached_run"
|
|
6
7
|
|
|
7
8
|
# Namespace for the Kward CLI agent runtime.
|
|
8
9
|
module Kward
|
|
@@ -10,7 +11,7 @@ module Kward
|
|
|
10
11
|
# This is intentionally low level: UI orchestration decides when terminal
|
|
11
12
|
# ownership is handed to the child process and how the result is presented.
|
|
12
13
|
class InteractivePtyRunner
|
|
13
|
-
Result = Struct.new(:exit_status, :input_forwarded, keyword_init: true)
|
|
14
|
+
Result = Struct.new(:exit_status, :input_forwarded, :tab_action, :background, keyword_init: true)
|
|
14
15
|
|
|
15
16
|
READ_SIZE = 4096
|
|
16
17
|
|
|
@@ -19,28 +20,43 @@ module Kward
|
|
|
19
20
|
@window_size = nil
|
|
20
21
|
end
|
|
21
22
|
|
|
22
|
-
def run(*command, env: {}, cwd: Dir.pwd, input: $stdin, sink:)
|
|
23
|
-
pid =
|
|
23
|
+
def run(*command, env: {}, cwd: Dir.pwd, input: $stdin, sink:, tab_action_handler: nil, on_detach: nil)
|
|
24
|
+
reader, writer, pid = PTY.spawn(env.to_h, *command, chdir: cwd.to_s)
|
|
24
25
|
status = nil
|
|
25
|
-
writer = nil
|
|
26
26
|
input_forwarded = false
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
27
|
+
tab_action = nil
|
|
28
|
+
forwarded = false
|
|
29
|
+
detached = nil
|
|
30
|
+
|
|
31
|
+
update_window_size(reader, pid)
|
|
32
|
+
with_raw_input(input) do
|
|
33
|
+
input_forwarded, tab_action = drain_initial_input(input, writer, sink, tab_action_handler: tab_action_handler)
|
|
34
|
+
if tab_action && on_detach
|
|
35
|
+
detached_sink = on_detach.call
|
|
36
|
+
sink.finish if sink.respond_to?(:finish)
|
|
37
|
+
detached = detach_process(reader, writer, pid, detached_sink, input_forwarded)
|
|
38
|
+
reader = writer = pid = nil
|
|
39
|
+
elsif tab_action
|
|
40
|
+
status = terminate_and_reap(pid)
|
|
41
|
+
else
|
|
42
|
+
status, forwarded, tab_action = forward_io(reader, writer, pid, input, sink, tab_action_handler: tab_action_handler, on_detach: on_detach)
|
|
35
43
|
input_forwarded ||= forwarded
|
|
44
|
+
if tab_action && on_detach
|
|
45
|
+
detached_sink = on_detach.call
|
|
46
|
+
sink.finish if sink.respond_to?(:finish)
|
|
47
|
+
detached = detach_process(reader, writer, pid, detached_sink, input_forwarded)
|
|
48
|
+
reader = writer = pid = nil
|
|
49
|
+
end
|
|
36
50
|
end
|
|
37
|
-
|
|
38
|
-
status ||= wait_for_status(pid)
|
|
51
|
+
input_forwarded ||= forwarded
|
|
39
52
|
end
|
|
53
|
+
sink.finish if sink.respond_to?(:finish) && !detached
|
|
54
|
+
status ||= wait_for_status(pid) if pid
|
|
40
55
|
|
|
41
|
-
Result.new(exit_status: exit_status(status), input_forwarded: input_forwarded)
|
|
56
|
+
Result.new(exit_status: exit_status(status), input_forwarded: input_forwarded, tab_action: tab_action, background: detached)
|
|
42
57
|
ensure
|
|
43
58
|
writer&.close unless writer&.closed?
|
|
59
|
+
reader&.close unless reader&.closed?
|
|
44
60
|
terminate_and_reap(pid) if pid && status.nil?
|
|
45
61
|
end
|
|
46
62
|
|
|
@@ -60,23 +76,39 @@ module Kward
|
|
|
60
76
|
yield
|
|
61
77
|
end
|
|
62
78
|
|
|
63
|
-
def drain_initial_input(input, writer, sink)
|
|
79
|
+
def drain_initial_input(input, writer, sink, tab_action_handler: nil)
|
|
64
80
|
forwarded = false
|
|
65
81
|
loop do
|
|
66
82
|
chunk = input.read_nonblock(READ_SIZE, exception: false)
|
|
67
83
|
break if chunk.nil? || chunk == :wait_readable
|
|
68
84
|
|
|
85
|
+
filtered = tab_action_handler&.call(chunk)
|
|
86
|
+
if filtered
|
|
87
|
+
tab_action = filtered[:tab_action]
|
|
88
|
+
chunk = filtered[:input].to_s
|
|
89
|
+
if tab_action
|
|
90
|
+
unless chunk.empty?
|
|
91
|
+
sink.input_forwarded if sink.respond_to?(:input_forwarded)
|
|
92
|
+
writer.write(chunk)
|
|
93
|
+
writer.flush
|
|
94
|
+
forwarded = true
|
|
95
|
+
end
|
|
96
|
+
return [forwarded, tab_action]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
next if chunk.empty?
|
|
100
|
+
|
|
69
101
|
sink.input_forwarded if sink.respond_to?(:input_forwarded)
|
|
70
102
|
writer.write(chunk)
|
|
71
103
|
forwarded = true
|
|
72
104
|
end
|
|
73
105
|
writer.flush
|
|
74
|
-
forwarded
|
|
106
|
+
[forwarded, nil]
|
|
75
107
|
rescue Errno::EIO, Errno::EPIPE, IOError
|
|
76
|
-
forwarded
|
|
108
|
+
[forwarded, nil]
|
|
77
109
|
end
|
|
78
110
|
|
|
79
|
-
def forward_io(reader, writer, pid, input, sink)
|
|
111
|
+
def forward_io(reader, writer, pid, input, sink, tab_action_handler: nil, on_detach: nil)
|
|
80
112
|
input_open = true
|
|
81
113
|
input_forwarded = false
|
|
82
114
|
loop do
|
|
@@ -86,19 +118,45 @@ module Kward
|
|
|
86
118
|
readable = IO.select(readers, nil, nil, 0.02)&.first || []
|
|
87
119
|
forward_pty_output(reader, sink) if readable.include?(reader)
|
|
88
120
|
if input_open && readable.include?(input)
|
|
89
|
-
input_open, forwarded = forward_input(input, writer, sink)
|
|
121
|
+
input_open, forwarded, tab_action = forward_input(input, writer, sink, tab_action_handler: tab_action_handler)
|
|
90
122
|
input_forwarded ||= forwarded
|
|
123
|
+
return [nil, input_forwarded, tab_action] if tab_action && on_detach
|
|
124
|
+
return [terminate_and_reap(pid), input_forwarded, tab_action] if tab_action
|
|
91
125
|
end
|
|
92
126
|
|
|
93
127
|
status = finished_status(pid)
|
|
94
128
|
next unless status
|
|
95
129
|
|
|
96
|
-
return [finish_stopped_process(pid), input_forwarded] if status.stopped?
|
|
130
|
+
return [finish_stopped_process(pid), input_forwarded, nil] if status.stopped?
|
|
97
131
|
|
|
98
132
|
drain_pty_output(reader, sink)
|
|
99
|
-
return [status, input_forwarded]
|
|
133
|
+
return [status, input_forwarded, nil]
|
|
100
134
|
rescue Errno::EIO
|
|
101
|
-
return [wait_for_status(pid), input_forwarded]
|
|
135
|
+
return [wait_for_status(pid), input_forwarded, nil]
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def detach_process(reader, writer, pid, sink, input_forwarded)
|
|
140
|
+
DetachedRun.new(sink: sink, canceler: -> { terminate_process_group(pid) }) do
|
|
141
|
+
status = nil
|
|
142
|
+
begin
|
|
143
|
+
loop do
|
|
144
|
+
readable, = IO.select([reader], nil, nil, 0.02)&.first
|
|
145
|
+
forward_pty_output(reader, sink) if readable
|
|
146
|
+
status = finished_status(pid)
|
|
147
|
+
next unless status
|
|
148
|
+
|
|
149
|
+
drain_pty_output(reader, sink)
|
|
150
|
+
break
|
|
151
|
+
end
|
|
152
|
+
rescue Errno::EIO
|
|
153
|
+
status = wait_for_status(pid)
|
|
154
|
+
ensure
|
|
155
|
+
sink.finish if sink.respond_to?(:finish)
|
|
156
|
+
writer.close unless writer.closed?
|
|
157
|
+
reader.close unless reader.closed?
|
|
158
|
+
end
|
|
159
|
+
Result.new(exit_status: exit_status(status), input_forwarded: input_forwarded)
|
|
102
160
|
end
|
|
103
161
|
end
|
|
104
162
|
|
|
@@ -125,17 +183,33 @@ module Kward
|
|
|
125
183
|
nil
|
|
126
184
|
end
|
|
127
185
|
|
|
128
|
-
def forward_input(input, writer, sink)
|
|
186
|
+
def forward_input(input, writer, sink, tab_action_handler: nil)
|
|
129
187
|
chunk = input.read_nonblock(READ_SIZE, exception: false)
|
|
130
|
-
return [false, false] if chunk.nil?
|
|
131
|
-
return [true, false] if chunk == :wait_readable
|
|
188
|
+
return [false, false, nil] if chunk.nil?
|
|
189
|
+
return [true, false, nil] if chunk == :wait_readable
|
|
190
|
+
|
|
191
|
+
filtered = tab_action_handler&.call(chunk)
|
|
192
|
+
if filtered
|
|
193
|
+
tab_action = filtered[:tab_action]
|
|
194
|
+
chunk = filtered[:input].to_s
|
|
195
|
+
if tab_action
|
|
196
|
+
unless chunk.empty?
|
|
197
|
+
sink.input_forwarded if sink.respond_to?(:input_forwarded)
|
|
198
|
+
writer.write(chunk)
|
|
199
|
+
writer.flush
|
|
200
|
+
return [true, true, tab_action]
|
|
201
|
+
end
|
|
202
|
+
return [true, false, tab_action]
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
return [true, false, nil] if chunk.empty?
|
|
132
206
|
|
|
133
207
|
sink.input_forwarded if sink.respond_to?(:input_forwarded)
|
|
134
208
|
writer.write(chunk)
|
|
135
209
|
writer.flush
|
|
136
|
-
[true, true]
|
|
210
|
+
[true, true, nil]
|
|
137
211
|
rescue Errno::EIO, Errno::EPIPE, IOError
|
|
138
|
-
[false, false]
|
|
212
|
+
[false, false, nil]
|
|
139
213
|
end
|
|
140
214
|
|
|
141
215
|
def update_window_size(reader, pid)
|
|
@@ -10,10 +10,10 @@ end
|
|
|
10
10
|
# Namespace for the Kward CLI agent runtime.
|
|
11
11
|
module Kward
|
|
12
12
|
# Kward-native embedded shell command runner.
|
|
13
|
-
class
|
|
13
|
+
class Kwsh
|
|
14
14
|
Result = Struct.new(:output, :exit_status, :exit_shell, :clear, :open_editor_path, :interactive_command, :streamed, keyword_init: true)
|
|
15
15
|
Completion = Struct.new(:range, :replacement, :candidates, keyword_init: true)
|
|
16
|
-
BUILTINS = %w[alias capture cd pwd export unset unalias clear exit logout pty].freeze
|
|
16
|
+
BUILTINS = %w[alias capture cd pwd export source unset unalias clear exit logout pty].freeze
|
|
17
17
|
DEFAULT_SHELL = "/bin/sh"
|
|
18
18
|
DEFAULT_TIMEOUT_SECONDS = 300
|
|
19
19
|
DEFAULT_MAX_OUTPUT_BYTES = 1_048_576
|
|
@@ -60,7 +60,7 @@ module Kward
|
|
|
60
60
|
end
|
|
61
61
|
|
|
62
62
|
# Runs a command for the shell assistant without handing it the terminal.
|
|
63
|
-
# Built-ins still execute in this
|
|
63
|
+
# Built-ins still execute in this Kwsh instance so their state persists.
|
|
64
64
|
def run_for_agent(input, timeout_seconds: nil, cancellation: nil)
|
|
65
65
|
command = input.to_s.strip
|
|
66
66
|
return run(command, cancellation: cancellation) if command.empty?
|
|
@@ -322,12 +322,12 @@ module Kward
|
|
|
322
322
|
return nil unless %w[exit logout].include?(words.first)
|
|
323
323
|
|
|
324
324
|
if words.length > 2 || (words[1] && !words[1].match?(/\A\d+\z/))
|
|
325
|
-
return Result.new(output: "#{command_echo(display_command)}
|
|
325
|
+
return Result.new(output: "#{command_echo(display_command)}kwsh: #{words.first}: numeric status expected\n", exit_status: 2)
|
|
326
326
|
end
|
|
327
327
|
|
|
328
328
|
Result.new(output: command_echo(display_command), exit_status: words[1].to_i, exit_shell: true)
|
|
329
329
|
rescue ArgumentError => e
|
|
330
|
-
Result.new(output: "#{command_echo(display_command)}
|
|
330
|
+
Result.new(output: "#{command_echo(display_command)}kwsh: #{e.message}\n", exit_status: 2)
|
|
331
331
|
end
|
|
332
332
|
|
|
333
333
|
def builtin_result(command, display_command: command, cancellation: nil, &block)
|
|
@@ -347,6 +347,8 @@ module Kward
|
|
|
347
347
|
print_working_directory(display_command, words)
|
|
348
348
|
when "export"
|
|
349
349
|
export_variables(display_command, words)
|
|
350
|
+
when "source", "."
|
|
351
|
+
source_file(display_command, words)
|
|
350
352
|
when "unset"
|
|
351
353
|
unset_variables(display_command, words)
|
|
352
354
|
when "clear"
|
|
@@ -359,7 +361,7 @@ module Kward
|
|
|
359
361
|
nil
|
|
360
362
|
end
|
|
361
363
|
rescue ArgumentError => e
|
|
362
|
-
Result.new(output: "#{command_echo(display_command)}
|
|
364
|
+
Result.new(output: "#{command_echo(display_command)}kwsh: #{e.message}\n", exit_status: 2)
|
|
363
365
|
end
|
|
364
366
|
|
|
365
367
|
def captured_command_result(command, display_command:, cancellation: nil, &block)
|
|
@@ -399,7 +401,7 @@ module Kward
|
|
|
399
401
|
invalid << name
|
|
400
402
|
end
|
|
401
403
|
end
|
|
402
|
-
return Result.new(output: "#{command_echo(command)}
|
|
404
|
+
return Result.new(output: "#{command_echo(command)}kwsh: alias: invalid name: #{invalid.join(" ")}\n", exit_status: 2) unless invalid.empty?
|
|
403
405
|
|
|
404
406
|
names = @aliases.keys.sort if names.empty? && assignments.empty?
|
|
405
407
|
lines = names.filter_map { |name| @aliases[name] ? "alias #{name}=#{Shellwords.escape(@aliases.fetch(name))}" : nil }
|
|
@@ -417,7 +419,7 @@ module Kward
|
|
|
417
419
|
end
|
|
418
420
|
|
|
419
421
|
missing = words.drop(1).reject { |name| @aliases.delete(name) }
|
|
420
|
-
return Result.new(output: "#{command_echo(command)}
|
|
422
|
+
return Result.new(output: "#{command_echo(command)}kwsh: unalias: not found: #{missing.join(" ")}\n", exit_status: 1) unless missing.empty?
|
|
421
423
|
|
|
422
424
|
Result.new(output: command_echo(command), exit_status: 0)
|
|
423
425
|
end
|
|
@@ -455,7 +457,7 @@ module Kward
|
|
|
455
457
|
path = File.expand_path(words[2], @cwd)
|
|
456
458
|
Result.new(output: command_echo(display_command), exit_status: 0, open_editor_path: path)
|
|
457
459
|
rescue ArgumentError => e
|
|
458
|
-
Result.new(output: "#{command_echo(display_command)}
|
|
460
|
+
Result.new(output: "#{command_echo(display_command)}kwsh: #{e.message}\n", exit_status: 2)
|
|
459
461
|
end
|
|
460
462
|
|
|
461
463
|
def kward_edit_command?(words)
|
|
@@ -489,7 +491,7 @@ module Kward
|
|
|
489
491
|
|
|
490
492
|
def change_directory(command, words)
|
|
491
493
|
if words.length > 2
|
|
492
|
-
return Result.new(output: "#{command_echo(command)}
|
|
494
|
+
return Result.new(output: "#{command_echo(command)}kwsh: cd: too many arguments\n", exit_status: 2)
|
|
493
495
|
end
|
|
494
496
|
|
|
495
497
|
target = words[1]
|
|
@@ -497,7 +499,7 @@ module Kward
|
|
|
497
499
|
target = @previous_cwd || @cwd if target == "-"
|
|
498
500
|
path = File.expand_path(target, @cwd)
|
|
499
501
|
unless File.directory?(path)
|
|
500
|
-
return Result.new(output: "#{command_echo(command)}
|
|
502
|
+
return Result.new(output: "#{command_echo(command)}kwsh: cd: no such directory: #{target}\n", exit_status: 1)
|
|
501
503
|
end
|
|
502
504
|
|
|
503
505
|
@previous_cwd = @cwd
|
|
@@ -528,10 +530,27 @@ module Kward
|
|
|
528
530
|
if invalid.empty?
|
|
529
531
|
Result.new(output: command_echo(command), exit_status: 0)
|
|
530
532
|
else
|
|
531
|
-
Result.new(output: "#{command_echo(command)}
|
|
533
|
+
Result.new(output: "#{command_echo(command)}kwsh: export: invalid assignment: #{invalid.join(" ")}\n", exit_status: 2)
|
|
532
534
|
end
|
|
533
535
|
end
|
|
534
536
|
|
|
537
|
+
def source_file(command, words)
|
|
538
|
+
unless words.length == 2
|
|
539
|
+
return Result.new(output: "#{command_echo(command)}Usage: source <file>\n", exit_status: 2)
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
require_relative "kwshrc"
|
|
543
|
+
path = Kwshrc.resolve_path(words[1], cwd: @cwd, env: @env)
|
|
544
|
+
config = Kwshrc.read_file(path, env: @env)
|
|
545
|
+
path_changed = config[:env].key?("PATH")
|
|
546
|
+
@env.merge!(config[:env])
|
|
547
|
+
@aliases.merge!(config[:aliases])
|
|
548
|
+
invalidate_path_executables_cache if path_changed
|
|
549
|
+
Result.new(output: command_echo(command), exit_status: 0)
|
|
550
|
+
rescue Kwshrc::ParseError => e
|
|
551
|
+
Result.new(output: "#{command_echo(command)}kwsh: source: #{e.message}\n", exit_status: 1)
|
|
552
|
+
end
|
|
553
|
+
|
|
535
554
|
def unset_variables(command, words)
|
|
536
555
|
names = words.drop(1)
|
|
537
556
|
names.shift if names.first == "--"
|
|
@@ -541,7 +560,7 @@ module Kward
|
|
|
541
560
|
if invalid.empty?
|
|
542
561
|
Result.new(output: command_echo(command), exit_status: 0)
|
|
543
562
|
else
|
|
544
|
-
Result.new(output: "#{command_echo(command)}
|
|
563
|
+
Result.new(output: "#{command_echo(command)}kwsh: unset: invalid name: #{invalid.join(" ")}\n", exit_status: 2)
|
|
545
564
|
end
|
|
546
565
|
end
|
|
547
566
|
|
|
@@ -575,8 +594,8 @@ module Kward
|
|
|
575
594
|
end
|
|
576
595
|
append_output_newline(output) { |text| yield text if streamed }
|
|
577
596
|
exit_status = result.timed_out || result.truncated ? 1 : (result.exit_status || 1)
|
|
578
|
-
append_streamed(output, "
|
|
579
|
-
append_streamed(output, "
|
|
597
|
+
append_streamed(output, "kwsh: command timed out after #{timeout} seconds\n", streamed) { |text| yield text } if result.timed_out
|
|
598
|
+
append_streamed(output, "kwsh: output exceeded #{@max_output_bytes} bytes; command terminated\n", streamed) { |text| yield text } if result.truncated
|
|
580
599
|
append_streamed(output, "Exit status: #{exit_status}\n", streamed) { |text| yield text } unless exit_status.zero?
|
|
581
600
|
Result.new(output: output, exit_status: exit_status, streamed: streamed)
|
|
582
601
|
rescue Cancellation::CancelledError
|
|
@@ -584,7 +603,7 @@ module Kward
|
|
|
584
603
|
append_streamed(output, "^C\nExit status: 130\n", streamed) { |text| yield text }
|
|
585
604
|
Result.new(output: output, exit_status: 130, streamed: streamed)
|
|
586
605
|
rescue Errno::ENOENT => e
|
|
587
|
-
Result.new(output: "#{command_echo(display_command)}
|
|
606
|
+
Result.new(output: "#{command_echo(display_command)}kwsh: #{e.message}\n", exit_status: 127)
|
|
588
607
|
end
|
|
589
608
|
|
|
590
609
|
def effective_timeout(timeout_seconds)
|
data/lib/kward/kwshrc.rb
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
require "shellwords"
|
|
2
|
+
require_relative "kwsh"
|
|
3
|
+
|
|
4
|
+
# Namespace for the Kward CLI agent runtime.
|
|
5
|
+
module Kward
|
|
6
|
+
# Parses the declarative subset of a shell startup file used by kwsh.
|
|
7
|
+
#
|
|
8
|
+
# Only aliases, exports, and source directives are handled. Other shell
|
|
9
|
+
# syntax is intentionally ignored until kwsh supports scripting.
|
|
10
|
+
class Kwshrc
|
|
11
|
+
VARIABLE_NAME = /\A[A-Za-z_][A-Za-z0-9_]*\z/
|
|
12
|
+
VARIABLE_PATTERN = /[A-Za-z_][A-Za-z0-9_]*/
|
|
13
|
+
|
|
14
|
+
class ParseError < StandardError
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.read(paths, env: ENV.to_h)
|
|
18
|
+
new(env: env).read(paths)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.read_file(path, env: ENV.to_h)
|
|
22
|
+
parser = new(env: env)
|
|
23
|
+
parser.read_path(path)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.resolve_path(path, cwd:, env: ENV.to_h)
|
|
27
|
+
parser = new(env: env)
|
|
28
|
+
expanded = parser.send(:expand_variables, path.to_s)
|
|
29
|
+
expanded = expanded.sub(/\A~(?=\/|\z)/, Dir.home)
|
|
30
|
+
File.expand_path(expanded, cwd)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def initialize(env: ENV.to_h)
|
|
34
|
+
@environment = env.to_h.transform_keys(&:to_s).transform_values(&:to_s)
|
|
35
|
+
@exports = {}
|
|
36
|
+
@aliases = {}
|
|
37
|
+
@loading = []
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def read(paths)
|
|
41
|
+
Array(paths).each { |path| read_file(path, required: false) }
|
|
42
|
+
configuration
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def read_path(path)
|
|
46
|
+
read_file(path, required: true)
|
|
47
|
+
configuration
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def configuration
|
|
53
|
+
{ env: @exports, aliases: @aliases }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def read_file(path, required:)
|
|
57
|
+
path = File.expand_path(path.to_s)
|
|
58
|
+
unless File.file?(path)
|
|
59
|
+
raise ParseError, "#{path}: file not found" if required
|
|
60
|
+
return
|
|
61
|
+
end
|
|
62
|
+
raise ParseError, "#{path}: recursive source" if @loading.include?(path)
|
|
63
|
+
|
|
64
|
+
@loading << path
|
|
65
|
+
File.foreach(path).with_index(1) do |line, line_number|
|
|
66
|
+
parse_line(strip_comment(line).strip, path, line_number)
|
|
67
|
+
end
|
|
68
|
+
rescue Errno::EACCES, Errno::EISDIR, Errno::ENOENT => e
|
|
69
|
+
raise ParseError, "#{path}: #{e.message}"
|
|
70
|
+
ensure
|
|
71
|
+
@loading.delete(path)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def parse_line(line, path, line_number)
|
|
75
|
+
return if line.empty?
|
|
76
|
+
|
|
77
|
+
words = Shellwords.split(line)
|
|
78
|
+
return if words.empty?
|
|
79
|
+
|
|
80
|
+
case words.shift
|
|
81
|
+
when "alias"
|
|
82
|
+
parse_aliases(words)
|
|
83
|
+
when "export"
|
|
84
|
+
parse_exports(expand_variables(line), path, line_number)
|
|
85
|
+
when "source", "."
|
|
86
|
+
parse_source(expand_variables(line), path, line_number)
|
|
87
|
+
end
|
|
88
|
+
rescue ArgumentError => e
|
|
89
|
+
raise ParseError, "#{path}:#{line_number}: #{e.message}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def parse_aliases(words)
|
|
93
|
+
words.each do |assignment|
|
|
94
|
+
name, command = assignment.split("=", 2)
|
|
95
|
+
next unless command && Kwsh.valid_alias_name?(name)
|
|
96
|
+
next if command.empty?
|
|
97
|
+
|
|
98
|
+
@aliases[name] = command
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def parse_exports(line, path, line_number)
|
|
103
|
+
words = Shellwords.split(line)
|
|
104
|
+
assignments = words.drop(1)
|
|
105
|
+
raise ParseError, "#{path}:#{line_number}: export requires a variable" if assignments.empty?
|
|
106
|
+
|
|
107
|
+
assignments.each do |assignment|
|
|
108
|
+
name, value = assignment.split("=", 2)
|
|
109
|
+
next unless name.match?(VARIABLE_NAME)
|
|
110
|
+
|
|
111
|
+
value = @environment[name] if value.nil? && @environment.key?(name)
|
|
112
|
+
next if value.nil?
|
|
113
|
+
|
|
114
|
+
@environment[name] = value
|
|
115
|
+
@exports[name] = value
|
|
116
|
+
end
|
|
117
|
+
rescue ArgumentError => e
|
|
118
|
+
raise ParseError, "#{path}:#{line_number}: #{e.message}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def parse_source(line, path, line_number)
|
|
122
|
+
words = Shellwords.split(line)
|
|
123
|
+
source_path = words[1]
|
|
124
|
+
if words.length != 2 || source_path.to_s.empty?
|
|
125
|
+
raise ParseError, "#{path}:#{line_number}: source requires one file"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
source_path = File.expand_path(source_path, File.dirname(path))
|
|
129
|
+
read_file(source_path, required: true)
|
|
130
|
+
rescue ArgumentError => e
|
|
131
|
+
raise ParseError, "#{path}:#{line_number}: #{e.message}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def strip_comment(line)
|
|
135
|
+
result = +""
|
|
136
|
+
quote = nil
|
|
137
|
+
escaped = false
|
|
138
|
+
|
|
139
|
+
line.each_char do |character|
|
|
140
|
+
if escaped
|
|
141
|
+
result << character
|
|
142
|
+
escaped = false
|
|
143
|
+
elsif character == "\\" && quote != "'"
|
|
144
|
+
result << character
|
|
145
|
+
escaped = true
|
|
146
|
+
elsif quote
|
|
147
|
+
result << character
|
|
148
|
+
quote = nil if character == quote
|
|
149
|
+
elsif character == "'" || character == '"'
|
|
150
|
+
result << character
|
|
151
|
+
quote = character
|
|
152
|
+
elsif character == "#"
|
|
153
|
+
break
|
|
154
|
+
else
|
|
155
|
+
result << character
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
result
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def expand_variables(text)
|
|
162
|
+
result = +""
|
|
163
|
+
quote = nil
|
|
164
|
+
escaped = false
|
|
165
|
+
index = 0
|
|
166
|
+
|
|
167
|
+
while index < text.length
|
|
168
|
+
character = text[index]
|
|
169
|
+
if escaped
|
|
170
|
+
result << character
|
|
171
|
+
escaped = false
|
|
172
|
+
index += 1
|
|
173
|
+
next
|
|
174
|
+
end
|
|
175
|
+
if character == "\\" && quote != "'"
|
|
176
|
+
result << character
|
|
177
|
+
escaped = true
|
|
178
|
+
index += 1
|
|
179
|
+
next
|
|
180
|
+
end
|
|
181
|
+
if character == "'"
|
|
182
|
+
quote = if quote == "'"
|
|
183
|
+
nil
|
|
184
|
+
elsif quote.nil?
|
|
185
|
+
character
|
|
186
|
+
else
|
|
187
|
+
quote
|
|
188
|
+
end
|
|
189
|
+
result << character
|
|
190
|
+
index += 1
|
|
191
|
+
next
|
|
192
|
+
end
|
|
193
|
+
if character == '"'
|
|
194
|
+
quote = if quote == '"'
|
|
195
|
+
nil
|
|
196
|
+
elsif quote.nil?
|
|
197
|
+
character
|
|
198
|
+
else
|
|
199
|
+
quote
|
|
200
|
+
end
|
|
201
|
+
result << character
|
|
202
|
+
index += 1
|
|
203
|
+
next
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
if character == "$" && quote != "'"
|
|
207
|
+
name, length = variable_at(text, index)
|
|
208
|
+
if name
|
|
209
|
+
result << @environment.fetch(name, "")
|
|
210
|
+
index += length
|
|
211
|
+
next
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
result << character
|
|
216
|
+
index += 1
|
|
217
|
+
end
|
|
218
|
+
result
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def variable_at(text, index)
|
|
222
|
+
if text[index + 1] == "{"
|
|
223
|
+
closing = text.index("}", index + 2)
|
|
224
|
+
name = text[(index + 2)...closing] if closing
|
|
225
|
+
return [name, closing - index + 1] if name&.match?(VARIABLE_NAME)
|
|
226
|
+
else
|
|
227
|
+
match = text[(index + 1)..].match(/\A#{VARIABLE_PATTERN}/)
|
|
228
|
+
return [match[0], match[0].length + 1] if match
|
|
229
|
+
end
|
|
230
|
+
nil
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|