kward 0.82.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 +96 -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/agent-tools.md +1 -0
- data/doc/api.md +4 -0
- data/doc/composer.md +2 -2
- data/doc/configuration.md +82 -20
- data/doc/editor.md +47 -22
- data/doc/files.md +12 -7
- data/doc/getting-started.md +3 -0
- data/doc/pan.md +19 -15
- data/doc/permissions.md +1 -0
- data/doc/platform-support.md +48 -0
- data/doc/releasing.md +12 -4
- data/doc/rpc.md +2 -2
- data/doc/sandboxing.md +5 -1
- data/doc/security.md +4 -3
- data/doc/shell.md +101 -60
- data/doc/tabs.md +3 -0
- data/doc/troubleshooting.md +12 -2
- data/doc/usage.md +7 -6
- data/kward.gemspec +5 -4
- data/lib/kward/agent.rb +7 -3
- data/lib/kward/ansi.rb +110 -10
- data/lib/kward/auth/anthropic_oauth.rb +7 -7
- 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 +144 -21
- 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 +260 -55
- data/lib/kward/cli/slash_commands.rb +21 -12
- data/lib/kward/cli/tabs.rb +91 -13
- data/lib/kward/cli/tool_summaries.rb +14 -0
- data/lib/kward/cli.rb +55 -7
- data/lib/kward/cli_transcript_formatter.rb +11 -4
- data/lib/kward/compaction/token_estimator.rb +12 -6
- data/lib/kward/config_files.rb +94 -63
- data/lib/kward/detached_run.rb +44 -0
- data/lib/kward/editor_prompt.rb +46 -0
- data/lib/kward/editor_prompt_session.rb +28 -0
- data/lib/kward/interactive_pty_runner.rb +102 -28
- data/lib/kward/{ekwsh.rb → kwsh.rb} +103 -24
- data/lib/kward/kwshrc.rb +233 -0
- data/lib/kward/markdown_code_block.rb +136 -0
- data/lib/kward/model/client.rb +35 -39
- data/lib/kward/model/model_info.rb +3 -2
- data/lib/kward/model/payloads.rb +0 -2
- 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 +843 -0
- data/lib/kward/project_files.rb +19 -6
- data/lib/kward/prompt_interface/composer_renderer.rb +45 -41
- data/lib/kward/prompt_interface/composer_state.rb +33 -24
- data/lib/kward/prompt_interface/editor/auto_indent.rb +27 -22
- data/lib/kward/prompt_interface/editor/controller.rb +99 -33
- data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
- data/lib/kward/prompt_interface/editor/modes/modern.rb +4 -0
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +298 -47
- data/lib/kward/prompt_interface/editor/renderer.rb +109 -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 +21 -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/editor/word_completion.rb +124 -0
- data/lib/kward/prompt_interface/file_overlay.rb +92 -22
- data/lib/kward/prompt_interface/key_handler.rb +75 -0
- data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
- data/lib/kward/prompt_interface/project_browser.rb +443 -17
- data/lib/kward/prompt_interface/runtime_state.rb +55 -2
- 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 +169 -18
- data/lib/kward/prompts/commands.rb +2 -1
- data/lib/kward/prompts.rb +1 -1
- data/lib/kward/pty_output_sink.rb +47 -0
- data/lib/kward/rpc/auth_manager.rb +1 -1
- data/lib/kward/rpc/server.rb +2 -1
- 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 +52 -0
- data/lib/kward/shell_prompt_session.rb +58 -0
- data/lib/kward/terminal_keys.rb +13 -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/prepare_shell_command.rb +28 -0
- data/lib/kward/tools/registry.rb +75 -8
- data/lib/kward/tools/replace_editor_buffer.rb +30 -0
- data/lib/kward/tools/run_shell_command.rb +24 -6
- 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 +44 -8
- data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Builds the model-facing request for an in-memory editor prompt.
|
|
4
|
+
module EditorPrompt
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def system_message
|
|
8
|
+
{
|
|
9
|
+
role: "system",
|
|
10
|
+
content: <<~PROMPT.strip
|
|
11
|
+
You are Kward's in-memory editor assistant.
|
|
12
|
+
|
|
13
|
+
Act only on the user's explicit request about the active editor buffer. Do not volunteer changes, explanations, or unrelated work. For an actionable edit request, call replace_editor_buffer with the complete replacement buffer. For a request that is only a question or needs clarification, answer briefly without changing the buffer. Never write files to disk and never claim that the buffer was saved.
|
|
14
|
+
PROMPT
|
|
15
|
+
}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def input(instruction, context)
|
|
19
|
+
document = context[:display_path].to_s
|
|
20
|
+
language = context[:language].to_s
|
|
21
|
+
language = "text" if language.empty?
|
|
22
|
+
content = context[:content].to_s
|
|
23
|
+
|
|
24
|
+
<<~PROMPT
|
|
25
|
+
You are operating inside an interactive editor.
|
|
26
|
+
|
|
27
|
+
The user requested:
|
|
28
|
+
#{instruction}
|
|
29
|
+
|
|
30
|
+
Active document: #{document.empty? ? "(untitled)" : document}
|
|
31
|
+
Language: #{language}
|
|
32
|
+
Cursor: line #{context.dig(:cursor, 0).to_i + 1}, column #{context.dig(:cursor, 1).to_i + 1}
|
|
33
|
+
Selected text: #{context[:selection].to_s.empty? ? "(none)" : "present; full-buffer replacement is required"}
|
|
34
|
+
|
|
35
|
+
Current buffer:
|
|
36
|
+
<editor_buffer>
|
|
37
|
+
#{content}
|
|
38
|
+
</editor_buffer>
|
|
39
|
+
|
|
40
|
+
Satisfy the user's request by calling replace_editor_buffer with the complete desired buffer contents.
|
|
41
|
+
The operation changes only the in-memory editor buffer. Do not write files to disk.
|
|
42
|
+
If the request is ambiguous, explain the ambiguity instead of changing the buffer.
|
|
43
|
+
PROMPT
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Transactional draft used while an agent works on an active editor buffer.
|
|
4
|
+
#
|
|
5
|
+
# The live terminal editor remains owned by PromptInterface. Agent tool calls
|
|
6
|
+
# update this draft and the caller commits it only after a successful turn.
|
|
7
|
+
class EditorPromptSession
|
|
8
|
+
attr_reader :context, :content
|
|
9
|
+
|
|
10
|
+
def initialize(context)
|
|
11
|
+
@context = context.dup
|
|
12
|
+
@initial_content = @context.fetch(:content).to_s.dup
|
|
13
|
+
@content = @initial_content.dup
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def replace(content)
|
|
17
|
+
@content = content.to_s
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def changed?
|
|
21
|
+
@content != @initial_content
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initial_content
|
|
25
|
+
@initial_content.dup
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -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,25 +10,24 @@ 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
|
|
20
20
|
DEFAULT_HISTORY_LIMIT = 1_000
|
|
21
|
+
CONTEXT_OUTPUT_BYTES = 32_000
|
|
21
22
|
|
|
22
|
-
attr_reader :cwd
|
|
23
|
+
attr_reader :cwd, :last_command, :timeout_seconds
|
|
23
24
|
|
|
24
25
|
def command_shell
|
|
25
26
|
@shell
|
|
26
27
|
end
|
|
27
28
|
|
|
28
29
|
def child_env(interactive: false)
|
|
29
|
-
|
|
30
|
-
env.delete("GIT_PAGER") if interactive && @defaulted_git_pager
|
|
31
|
-
env
|
|
30
|
+
@env.dup
|
|
32
31
|
end
|
|
33
32
|
|
|
34
33
|
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)
|
|
@@ -43,6 +42,8 @@ module Kward
|
|
|
43
42
|
@shell = shell.to_s.empty? ? DEFAULT_SHELL : shell.to_s
|
|
44
43
|
@timeout_seconds = timeout_seconds.to_i.positive? ? timeout_seconds.to_i : DEFAULT_TIMEOUT_SECONDS
|
|
45
44
|
@max_output_bytes = max_output_bytes.to_i.positive? ? max_output_bytes.to_i : DEFAULT_MAX_OUTPUT_BYTES
|
|
45
|
+
@last_command = nil
|
|
46
|
+
@last_result = nil
|
|
46
47
|
end
|
|
47
48
|
|
|
48
49
|
def prompt_label
|
|
@@ -53,7 +54,45 @@ module Kward
|
|
|
53
54
|
command = input.to_s.strip
|
|
54
55
|
return Result.new(output: "", exit_status: 0) if command.empty?
|
|
55
56
|
|
|
56
|
-
run_expanded_command(command, cancellation: cancellation, &block)
|
|
57
|
+
result = run_expanded_command(command, cancellation: cancellation, &block)
|
|
58
|
+
remember_result(command, result)
|
|
59
|
+
result
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Runs a command for the shell assistant without handing it the terminal.
|
|
63
|
+
# Built-ins still execute in this Kwsh instance so their state persists.
|
|
64
|
+
def run_for_agent(input, timeout_seconds: nil, cancellation: nil)
|
|
65
|
+
command = input.to_s.strip
|
|
66
|
+
return run(command, cancellation: cancellation) if command.empty?
|
|
67
|
+
|
|
68
|
+
result = run(command, cancellation: cancellation)
|
|
69
|
+
return result unless result.interactive_command
|
|
70
|
+
|
|
71
|
+
result = execute(
|
|
72
|
+
result.interactive_command,
|
|
73
|
+
display_command: command,
|
|
74
|
+
timeout_seconds: timeout_seconds,
|
|
75
|
+
cancellation: cancellation
|
|
76
|
+
)
|
|
77
|
+
remember_result(command, result)
|
|
78
|
+
result
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Records the output of an interactive command after terminal handoff.
|
|
82
|
+
def record_interactive_result(output:, exit_status:)
|
|
83
|
+
return unless @last_command
|
|
84
|
+
|
|
85
|
+
@last_result = Result.new(output: ANSI.sanitize_transcript(output.to_s), exit_status: exit_status)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Returns bounded shell state for an explicit shell-agent prompt.
|
|
89
|
+
def context_snapshot(max_output_bytes: CONTEXT_OUTPUT_BYTES)
|
|
90
|
+
{
|
|
91
|
+
cwd: @cwd,
|
|
92
|
+
last_command: @last_command,
|
|
93
|
+
exit_status: @last_result&.exit_status,
|
|
94
|
+
last_output: context_output(@last_result&.output, max_output_bytes)
|
|
95
|
+
}
|
|
57
96
|
end
|
|
58
97
|
|
|
59
98
|
def complete(input, cursor)
|
|
@@ -112,8 +151,6 @@ module Kward
|
|
|
112
151
|
def configure_color_environment
|
|
113
152
|
@env["CLICOLOR"] ||= "1"
|
|
114
153
|
@env["COLORTERM"] ||= "truecolor"
|
|
115
|
-
@defaulted_git_pager = !@env.key?("GIT_PAGER")
|
|
116
|
-
@env["GIT_PAGER"] ||= "cat"
|
|
117
154
|
@env["TERM"] = "xterm-256color" if @env["TERM"].to_s.empty? || @env["TERM"] == "dumb"
|
|
118
155
|
end
|
|
119
156
|
|
|
@@ -285,12 +322,12 @@ module Kward
|
|
|
285
322
|
return nil unless %w[exit logout].include?(words.first)
|
|
286
323
|
|
|
287
324
|
if words.length > 2 || (words[1] && !words[1].match?(/\A\d+\z/))
|
|
288
|
-
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)
|
|
289
326
|
end
|
|
290
327
|
|
|
291
328
|
Result.new(output: command_echo(display_command), exit_status: words[1].to_i, exit_shell: true)
|
|
292
329
|
rescue ArgumentError => e
|
|
293
|
-
Result.new(output: "#{command_echo(display_command)}
|
|
330
|
+
Result.new(output: "#{command_echo(display_command)}kwsh: #{e.message}\n", exit_status: 2)
|
|
294
331
|
end
|
|
295
332
|
|
|
296
333
|
def builtin_result(command, display_command: command, cancellation: nil, &block)
|
|
@@ -310,6 +347,8 @@ module Kward
|
|
|
310
347
|
print_working_directory(display_command, words)
|
|
311
348
|
when "export"
|
|
312
349
|
export_variables(display_command, words)
|
|
350
|
+
when "source", "."
|
|
351
|
+
source_file(display_command, words)
|
|
313
352
|
when "unset"
|
|
314
353
|
unset_variables(display_command, words)
|
|
315
354
|
when "clear"
|
|
@@ -322,7 +361,7 @@ module Kward
|
|
|
322
361
|
nil
|
|
323
362
|
end
|
|
324
363
|
rescue ArgumentError => e
|
|
325
|
-
Result.new(output: "#{command_echo(display_command)}
|
|
364
|
+
Result.new(output: "#{command_echo(display_command)}kwsh: #{e.message}\n", exit_status: 2)
|
|
326
365
|
end
|
|
327
366
|
|
|
328
367
|
def captured_command_result(command, display_command:, cancellation: nil, &block)
|
|
@@ -362,7 +401,7 @@ module Kward
|
|
|
362
401
|
invalid << name
|
|
363
402
|
end
|
|
364
403
|
end
|
|
365
|
-
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?
|
|
366
405
|
|
|
367
406
|
names = @aliases.keys.sort if names.empty? && assignments.empty?
|
|
368
407
|
lines = names.filter_map { |name| @aliases[name] ? "alias #{name}=#{Shellwords.escape(@aliases.fetch(name))}" : nil }
|
|
@@ -380,7 +419,7 @@ module Kward
|
|
|
380
419
|
end
|
|
381
420
|
|
|
382
421
|
missing = words.drop(1).reject { |name| @aliases.delete(name) }
|
|
383
|
-
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?
|
|
384
423
|
|
|
385
424
|
Result.new(output: command_echo(command), exit_status: 0)
|
|
386
425
|
end
|
|
@@ -418,7 +457,7 @@ module Kward
|
|
|
418
457
|
path = File.expand_path(words[2], @cwd)
|
|
419
458
|
Result.new(output: command_echo(display_command), exit_status: 0, open_editor_path: path)
|
|
420
459
|
rescue ArgumentError => e
|
|
421
|
-
Result.new(output: "#{command_echo(display_command)}
|
|
460
|
+
Result.new(output: "#{command_echo(display_command)}kwsh: #{e.message}\n", exit_status: 2)
|
|
422
461
|
end
|
|
423
462
|
|
|
424
463
|
def kward_edit_command?(words)
|
|
@@ -452,7 +491,7 @@ module Kward
|
|
|
452
491
|
|
|
453
492
|
def change_directory(command, words)
|
|
454
493
|
if words.length > 2
|
|
455
|
-
return Result.new(output: "#{command_echo(command)}
|
|
494
|
+
return Result.new(output: "#{command_echo(command)}kwsh: cd: too many arguments\n", exit_status: 2)
|
|
456
495
|
end
|
|
457
496
|
|
|
458
497
|
target = words[1]
|
|
@@ -460,7 +499,7 @@ module Kward
|
|
|
460
499
|
target = @previous_cwd || @cwd if target == "-"
|
|
461
500
|
path = File.expand_path(target, @cwd)
|
|
462
501
|
unless File.directory?(path)
|
|
463
|
-
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)
|
|
464
503
|
end
|
|
465
504
|
|
|
466
505
|
@previous_cwd = @cwd
|
|
@@ -491,8 +530,25 @@ module Kward
|
|
|
491
530
|
if invalid.empty?
|
|
492
531
|
Result.new(output: command_echo(command), exit_status: 0)
|
|
493
532
|
else
|
|
494
|
-
Result.new(output: "#{command_echo(command)}
|
|
533
|
+
Result.new(output: "#{command_echo(command)}kwsh: export: invalid assignment: #{invalid.join(" ")}\n", exit_status: 2)
|
|
534
|
+
end
|
|
535
|
+
end
|
|
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)
|
|
495
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)
|
|
496
552
|
end
|
|
497
553
|
|
|
498
554
|
def unset_variables(command, words)
|
|
@@ -504,7 +560,7 @@ module Kward
|
|
|
504
560
|
if invalid.empty?
|
|
505
561
|
Result.new(output: command_echo(command), exit_status: 0)
|
|
506
562
|
else
|
|
507
|
-
Result.new(output: "#{command_echo(command)}
|
|
563
|
+
Result.new(output: "#{command_echo(command)}kwsh: unset: invalid name: #{invalid.join(" ")}\n", exit_status: 2)
|
|
508
564
|
end
|
|
509
565
|
end
|
|
510
566
|
|
|
@@ -522,12 +578,13 @@ module Kward
|
|
|
522
578
|
key.to_s.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
|
|
523
579
|
end
|
|
524
580
|
|
|
525
|
-
def execute(command, display_command: command, cancellation: nil)
|
|
581
|
+
def execute(command, display_command: command, timeout_seconds: nil, cancellation: nil)
|
|
526
582
|
output = command_echo(display_command)
|
|
527
583
|
streamed = block_given?
|
|
528
584
|
yield output.dup if streamed
|
|
585
|
+
timeout = effective_timeout(timeout_seconds)
|
|
529
586
|
result = external_command_runner.new(
|
|
530
|
-
timeout_seconds:
|
|
587
|
+
timeout_seconds: timeout,
|
|
531
588
|
max_output_bytes: @max_output_bytes,
|
|
532
589
|
terminate_on_output_limit: true
|
|
533
590
|
).run(@shell, "-c", command, env: @env, cwd: @cwd, cancellation: cancellation) do |_stream, chunk|
|
|
@@ -537,8 +594,8 @@ module Kward
|
|
|
537
594
|
end
|
|
538
595
|
append_output_newline(output) { |text| yield text if streamed }
|
|
539
596
|
exit_status = result.timed_out || result.truncated ? 1 : (result.exit_status || 1)
|
|
540
|
-
append_streamed(output, "
|
|
541
|
-
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
|
|
542
599
|
append_streamed(output, "Exit status: #{exit_status}\n", streamed) { |text| yield text } unless exit_status.zero?
|
|
543
600
|
Result.new(output: output, exit_status: exit_status, streamed: streamed)
|
|
544
601
|
rescue Cancellation::CancelledError
|
|
@@ -546,7 +603,29 @@ module Kward
|
|
|
546
603
|
append_streamed(output, "^C\nExit status: 130\n", streamed) { |text| yield text }
|
|
547
604
|
Result.new(output: output, exit_status: 130, streamed: streamed)
|
|
548
605
|
rescue Errno::ENOENT => e
|
|
549
|
-
Result.new(output: "#{command_echo(display_command)}
|
|
606
|
+
Result.new(output: "#{command_echo(display_command)}kwsh: #{e.message}\n", exit_status: 127)
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
def effective_timeout(timeout_seconds)
|
|
610
|
+
timeout = timeout_seconds.to_i
|
|
611
|
+
timeout.positive? ? timeout : @timeout_seconds
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
def remember_result(command, result)
|
|
615
|
+
@last_command = command.to_s
|
|
616
|
+
@last_result = result
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
def context_output(output, max_output_bytes)
|
|
620
|
+
text = ANSI.sanitize_transcript(output.to_s)
|
|
621
|
+
limit = max_output_bytes.to_i
|
|
622
|
+
return text if limit <= 0 || text.bytesize <= limit
|
|
623
|
+
|
|
624
|
+
prefix = "[Earlier output omitted; showing the end of the command output.]\n"
|
|
625
|
+
tail_limit = [limit - prefix.bytesize, 0].max
|
|
626
|
+
tail = text.byteslice(-tail_limit, tail_limit).to_s if tail_limit.positive?
|
|
627
|
+
value = "#{prefix}#{tail}"
|
|
628
|
+
ANSI.normalize_transcript_encoding(value.byteslice(-limit, limit).to_s)
|
|
550
629
|
end
|
|
551
630
|
|
|
552
631
|
def external_command_runner
|