kward 0.83.0 → 0.85.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 +102 -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 +13 -1
- data/doc/api.md +21 -2
- data/doc/composer.md +2 -2
- data/doc/configuration.md +95 -25
- data/doc/editor.md +28 -13
- data/doc/extensibility.md +2 -1
- data/doc/files.md +8 -4
- data/doc/getting-started.md +3 -0
- data/doc/git.md +3 -1
- data/doc/pan.md +25 -15
- data/doc/permissions.md +4 -4
- data/doc/platform-support.md +48 -0
- data/doc/plugins.md +464 -15
- data/doc/rpc.md +154 -16
- data/doc/sandboxing.md +11 -5
- data/doc/security.md +10 -3
- data/doc/session-management.md +5 -4
- data/doc/shell.md +62 -45
- data/doc/tabs.md +6 -2
- data/doc/transports.md +15 -0
- data/doc/troubleshooting.md +12 -2
- data/doc/usage.md +9 -6
- data/doc/workspace-tools.md +9 -0
- data/examples/plugins/space_invaders.rb +1 -1
- data/examples/plugins/stardate_footer.rb +2 -2
- data/examples/plugins/telegram/plugin.rb +1 -1
- data/kward.gemspec +5 -4
- data/lib/kward/agent.rb +30 -14
- data/lib/kward/cli/auth_commands.rb +34 -13
- data/lib/kward/cli/commands.rb +83 -62
- data/lib/kward/cli/compaction.rb +9 -3
- 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/plugins.rb +81 -12
- data/lib/kward/cli/project_skills_commands.rb +8 -4
- data/lib/kward/cli/prompt_interface.rb +52 -5
- data/lib/kward/cli/rendering.rb +18 -9
- data/lib/kward/cli/runtime_helpers.rb +228 -71
- data/lib/kward/cli/sessions.rb +9 -5
- data/lib/kward/cli/settings/menus.rb +745 -0
- data/lib/kward/cli/settings/model.rb +327 -0
- data/lib/kward/cli/settings.rb +6 -1055
- data/lib/kward/cli/slash_commands.rb +56 -17
- data/lib/kward/cli/tabs.rb +244 -33
- data/lib/kward/cli/tool_summaries.rb +14 -0
- data/lib/kward/{cli_transcript_formatter.rb → cli/transcript_formatter.rb} +14 -7
- data/lib/kward/cli/worktrees.rb +65 -2
- data/lib/kward/cli.rb +70 -30
- data/lib/kward/compactor.rb +18 -7
- data/lib/kward/config/core.rb +389 -0
- data/lib/kward/config/extensions.rb +96 -0
- data/lib/kward/config/prompts.rb +313 -0
- data/lib/kward/config/settings.rb +250 -0
- data/lib/kward/config_files.rb +14 -994
- data/lib/kward/conversation.rb +31 -2
- data/lib/kward/image_attachments.rb +1 -1
- data/lib/kward/model/client.rb +36 -24
- data/lib/kward/model/copilot_models.rb +2 -2
- data/lib/kward/model/model_info.rb +20 -3
- data/lib/kward/{openrouter_model_cache.rb → model/openrouter_model_cache.rb} +3 -3
- data/lib/kward/model/payloads.rb +12 -3
- data/lib/kward/model/provider_catalog.rb +5 -0
- data/lib/kward/model/stream_parser.rb +20 -4
- data/lib/kward/model/typesafe_client.rb +78 -0
- data/lib/kward/pan/index.html.erb +3 -3
- data/lib/kward/pan/server.rb +33 -10
- data/lib/kward/permissions/policy.rb +6 -2
- data/lib/kward/plugin_registry.rb +2 -659
- data/lib/kward/plugins/actions.rb +453 -0
- data/lib/kward/plugins/chat_contract.rb +121 -0
- data/lib/kward/{plugin_chat_runtime.rb → plugins/chat_runtime.rb} +62 -18
- data/lib/kward/plugins/host.rb +232 -0
- data/lib/kward/plugins/registry.rb +1190 -0
- data/lib/kward/plugins/resources.rb +206 -0
- data/lib/kward/plugins/turn_request.rb +36 -0
- data/lib/kward/plugins/ui.rb +219 -0
- 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/markdown_code_block.rb +136 -0
- 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/scratchpad_languages.rb +74 -0
- data/lib/kward/prompt_interface/editor/scratchpad_runner.rb +182 -0
- data/lib/kward/prompt_interface/editor/state.rb +11 -11
- 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/layout.rb +1 -1
- data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
- data/lib/kward/prompt_interface/plugin_ui_requests.rb +82 -0
- data/lib/kward/prompt_interface/project_browser.rb +415 -14
- data/lib/kward/prompt_interface/runtime_state.rb +56 -2
- data/lib/kward/prompt_interface/screen.rb +11 -4
- 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 +151 -27
- data/lib/kward/prompts/commands.rb +3 -2
- data/lib/kward/prompts.rb +1 -1
- data/lib/kward/{adaptive_pty_output_sink.rb → pty/adaptive_output_sink.rb} +1 -1
- data/lib/kward/pty/detached_run.rb +44 -0
- data/lib/kward/{interactive_pty_runner.rb → pty/interactive_runner.rb} +104 -30
- data/lib/kward/{local_command_runner.rb → pty/local_command_runner.rb} +1 -1
- data/lib/kward/{local_pty_command_runner.rb → pty/local_pty_runner.rb} +3 -9
- data/lib/kward/{pty_output_sink.rb → pty/output_sink.rb} +52 -0
- data/lib/kward/{pty_transcript_normalizer.rb → pty/transcript_normalizer.rb} +1 -1
- data/lib/kward/rpc/plugin_chat_manager.rb +30 -10
- data/lib/kward/rpc/prompt_bridge.rb +25 -0
- data/lib/kward/rpc/server.rb +91 -12
- data/lib/kward/rpc/session_manager.rb +147 -44
- data/lib/kward/rpc/session_tree_rows.rb +2 -2
- data/lib/kward/rpc/tool_metadata.rb +1 -1
- data/lib/kward/rpc/transcript_normalizer.rb +7 -3
- data/lib/kward/sandbox/command_runner.rb +1 -1
- data/lib/kward/{session_catalog.rb → sessions/catalog.rb} +1 -1
- data/lib/kward/{session_store.rb → sessions/store.rb} +9 -9
- data/lib/kward/{session_tree_nodes.rb → sessions/tree_nodes.rb} +3 -3
- data/lib/kward/{session_tree_renderer.rb → sessions/tree_renderer.rb} +4 -4
- data/lib/kward/{session_tree_tool_display.rb → sessions/tree_tool_display.rb} +1 -1
- data/lib/kward/{ekwsh.rb → shell/kwsh.rb} +38 -19
- data/lib/kward/shell/kwshrc.rb +233 -0
- data/lib/kward/{persistent_shell_session.rb → shell/persistent_session.rb} +121 -28
- data/lib/kward/{shell_prompt.rb → shell/prompt.rb} +2 -0
- data/lib/kward/{shell_prompt_session.rb → shell/prompt_session.rb} +1 -1
- data/lib/kward/skills/trust_store.rb +1 -1
- data/lib/kward/tabs/driver.rb +194 -0
- data/lib/kward/{tab_store.rb → tabs/store.rb} +2 -2
- data/lib/kward/{ansi.rb → terminal/ansi.rb} +110 -10
- data/lib/kward/{clipboard.rb → terminal/clipboard.rb} +1 -1
- data/lib/kward/{terminal_image_support.rb → terminal/image_support.rb} +1 -1
- data/lib/kward/{terminal_keys.rb → 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/base.rb +18 -0
- data/lib/kward/tools/context_for_task.rb +15 -6
- data/lib/kward/tools/edit_file.rb +9 -6
- data/lib/kward/tools/git_commit.rb +13 -7
- data/lib/kward/tools/list_directory.rb +4 -4
- data/lib/kward/tools/open_editor.rb +41 -0
- data/lib/kward/tools/plugin_tool.rb +41 -0
- data/lib/kward/tools/prepare_shell_command.rb +1 -1
- data/lib/kward/tools/read_file.rb +7 -6
- data/lib/kward/tools/registry.rb +109 -17
- data/lib/kward/tools/run_shell_command.rb +10 -8
- data/lib/kward/tools/search/code.rb +1 -1
- data/lib/kward/tools/summarize_file_structure.rb +5 -5
- data/lib/kward/tools/tool_call.rb +3 -1
- data/lib/kward/tools/typesafe_evaluate.rb +81 -0
- data/lib/kward/tools/workspace_targets.rb +58 -0
- data/lib/kward/tools/write_file.rb +9 -6
- data/lib/kward/{export_path.rb → transcripts/export_path.rb} +1 -1
- data/lib/kward/{markdown_transcript.rb → transcripts/markdown_transcript.rb} +2 -2
- data/lib/kward/transport/contracts.rb +200 -0
- data/lib/kward/transport/gateway.rb +79 -35
- data/lib/kward/transport/plugin_chat_gateway.rb +3 -2
- data/lib/kward/transport.rb +1 -200
- data/lib/kward/version.rb +1 -1
- data/lib/kward/{workspace_factory.rb → workspace/factory.rb} +2 -2
- data/lib/kward/{project_files.rb → workspace/files.rb} +2 -2
- data/lib/kward/{git_worktree_manager.rb → workspace/git_worktree_manager.rb} +28 -0
- data/lib/kward/{workspace.rb → workspace/workspace.rb} +3 -3
- 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 +2 -2
- data/templates/default/layout/html/setup.rb +41 -2
- metadata +94 -47
- data/lib/kward/scratchpad_runner.rb +0 -56
- data/lib/kward/tab_driver.rb +0 -90
- /data/lib/kward/{editor_prompt.rb → cli/editor_prompt.rb} +0 -0
- /data/lib/kward/{editor_prompt_session.rb → cli/editor_prompt_session.rb} +0 -0
- /data/lib/kward/{diff_view_mode.rb → prompt_interface/editor/diff_view_mode.rb} +0 -0
- /data/lib/kward/{editor_mode.rb → prompt_interface/editor/editor_mode.rb} +0 -0
- /data/lib/kward/{session_diff.rb → sessions/diff.rb} +0 -0
- /data/lib/kward/{session_naming.rb → sessions/naming.rb} +0 -0
- /data/lib/kward/{session_trash.rb → sessions/trash.rb} +0 -0
- /data/lib/kward/{terminal_sequences.rb → terminal/sequences.rb} +0 -0
- /data/lib/kward/{transcript_export.rb → transcripts/transcript_export.rb} +0 -0
- /data/lib/kward/{path_guard.rb → workspace/path_guard.rb} +0 -0
|
@@ -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
|
|
@@ -2,9 +2,11 @@ require "io/console"
|
|
|
2
2
|
require "pty"
|
|
3
3
|
require "securerandom"
|
|
4
4
|
require "shellwords"
|
|
5
|
-
require_relative "ansi"
|
|
6
|
-
require_relative "
|
|
7
|
-
require_relative "
|
|
5
|
+
require_relative "../terminal/ansi"
|
|
6
|
+
require_relative "kwsh"
|
|
7
|
+
require_relative "kwshrc"
|
|
8
|
+
require_relative "../pty/local_pty_runner"
|
|
9
|
+
require_relative "../pty/detached_run"
|
|
8
10
|
|
|
9
11
|
# Namespace for the Kward CLI agent runtime.
|
|
10
12
|
module Kward
|
|
@@ -13,8 +15,8 @@ module Kward
|
|
|
13
15
|
# The process is kept alive between commands so shell variables, functions,
|
|
14
16
|
# aliases, directory changes, and child-shell state belong to one session.
|
|
15
17
|
class PersistentShellSession
|
|
16
|
-
Result =
|
|
17
|
-
InteractiveResult = Struct.new(:exit_status, :input_forwarded, keyword_init: true)
|
|
18
|
+
Result = Kwsh::Result
|
|
19
|
+
InteractiveResult = Struct.new(:exit_status, :input_forwarded, :tab_action, :background, keyword_init: true)
|
|
18
20
|
|
|
19
21
|
class CommandInterrupted < StandardError
|
|
20
22
|
attr_reader :protocol
|
|
@@ -38,14 +40,14 @@ module Kward
|
|
|
38
40
|
|
|
39
41
|
attr_reader :cwd, :last_command, :timeout_seconds
|
|
40
42
|
|
|
41
|
-
def initialize(cwd: Dir.pwd, env: ENV.to_h, shell:
|
|
43
|
+
def initialize(cwd: Dir.pwd, env: ENV.to_h, shell: Kwsh::DEFAULT_SHELL, configured_env: {}, aliases: {}, timeout_seconds: Kwsh::DEFAULT_TIMEOUT_SECONDS, max_output_bytes: Kwsh::DEFAULT_MAX_OUTPUT_BYTES, window_size_provider: nil)
|
|
42
44
|
@cwd = File.expand_path(cwd.to_s.empty? ? Dir.pwd : cwd.to_s)
|
|
43
45
|
@base_env = env.to_h.transform_keys(&:to_s).transform_values(&:to_s)
|
|
44
46
|
@configured_env = configured_env.to_h.transform_keys(&:to_s).transform_values(&:to_s)
|
|
45
47
|
@aliases = aliases.to_h.transform_keys(&:to_s).transform_values(&:to_s)
|
|
46
|
-
@shell = shell.to_s.empty? ?
|
|
47
|
-
@timeout_seconds = timeout_seconds.to_i.positive? ? timeout_seconds.to_i :
|
|
48
|
-
@max_output_bytes = max_output_bytes.to_i.positive? ? max_output_bytes.to_i :
|
|
48
|
+
@shell = shell.to_s.empty? ? Kwsh::DEFAULT_SHELL : shell.to_s
|
|
49
|
+
@timeout_seconds = timeout_seconds.to_i.positive? ? timeout_seconds.to_i : Kwsh::DEFAULT_TIMEOUT_SECONDS
|
|
50
|
+
@max_output_bytes = max_output_bytes.to_i.positive? ? max_output_bytes.to_i : Kwsh::DEFAULT_MAX_OUTPUT_BYTES
|
|
49
51
|
@window_size_provider = window_size_provider
|
|
50
52
|
@last_command = nil
|
|
51
53
|
@last_result = nil
|
|
@@ -93,6 +95,8 @@ module Kward
|
|
|
93
95
|
capture_result(command, expanded_command, cancellation: cancellation, &block)
|
|
94
96
|
elsif words.first == "pty"
|
|
95
97
|
interactive_result(command, expanded_command)
|
|
98
|
+
elsif %w[source .].include?(words.first)
|
|
99
|
+
source_result(command, words)
|
|
96
100
|
elsif (editor_result = editor_command_result(command))
|
|
97
101
|
editor_result
|
|
98
102
|
elsif stateful_command?(words)
|
|
@@ -103,7 +107,7 @@ module Kward
|
|
|
103
107
|
remember_result(command, result)
|
|
104
108
|
result
|
|
105
109
|
rescue ArgumentError => e
|
|
106
|
-
result = Result.new(output: "#{command_echo(command)}
|
|
110
|
+
result = Result.new(output: "#{command_echo(command)}kwsh: #{e.message}\n", exit_status: 2)
|
|
107
111
|
remember_result(command, result)
|
|
108
112
|
result
|
|
109
113
|
end
|
|
@@ -129,14 +133,16 @@ module Kward
|
|
|
129
133
|
|
|
130
134
|
# Runs an external command through the same persistent shell process while
|
|
131
135
|
# the caller owns the terminal.
|
|
132
|
-
def run_interactive(command, input:, sink:)
|
|
136
|
+
def run_interactive(command, input:, sink:, tab_action_handler: nil, on_detach: nil)
|
|
133
137
|
protocol = with_raw_input(input) do
|
|
134
|
-
execute_protocol(command, input: input, sink: sink)
|
|
138
|
+
execute_protocol(command, input: input, sink: sink, tab_action_handler: tab_action_handler, on_detach: on_detach)
|
|
135
139
|
end
|
|
136
|
-
update_cwd(protocol[:cwd])
|
|
140
|
+
update_cwd(protocol[:cwd]) unless protocol[:background]
|
|
137
141
|
InteractiveResult.new(
|
|
138
142
|
exit_status: protocol[:status] || 1,
|
|
139
|
-
input_forwarded: protocol[:input_forwarded]
|
|
143
|
+
input_forwarded: protocol[:input_forwarded],
|
|
144
|
+
tab_action: protocol[:tab_action],
|
|
145
|
+
background: protocol[:background]
|
|
140
146
|
)
|
|
141
147
|
ensure
|
|
142
148
|
sink.finish if sink.respond_to?(:finish)
|
|
@@ -150,7 +156,7 @@ module Kward
|
|
|
150
156
|
end
|
|
151
157
|
|
|
152
158
|
# Returns bounded shell state for an explicit shell-agent prompt.
|
|
153
|
-
def context_snapshot(max_output_bytes:
|
|
159
|
+
def context_snapshot(max_output_bytes: Kwsh::CONTEXT_OUTPUT_BYTES)
|
|
154
160
|
{
|
|
155
161
|
cwd: @cwd,
|
|
156
162
|
last_command: @last_command,
|
|
@@ -207,7 +213,7 @@ module Kward
|
|
|
207
213
|
end
|
|
208
214
|
|
|
209
215
|
def build_routing_shell
|
|
210
|
-
|
|
216
|
+
Kwsh.new(
|
|
211
217
|
cwd: @cwd,
|
|
212
218
|
env: @env || @base_env,
|
|
213
219
|
shell: @shell,
|
|
@@ -253,7 +259,7 @@ module Kward
|
|
|
253
259
|
update_cwd(protocol[:cwd])
|
|
254
260
|
end
|
|
255
261
|
|
|
256
|
-
def execute_protocol(command, input: nil, sink: nil, timeout_seconds: nil, max_output_bytes: @max_output_bytes, cancellation: nil, suppress_git_pager: false)
|
|
262
|
+
def execute_protocol(command, input: nil, sink: nil, timeout_seconds: nil, max_output_bytes: @max_output_bytes, cancellation: nil, suppress_git_pager: false, tab_action_handler: nil, on_detach: nil)
|
|
257
263
|
@command_mutex.synchronize do
|
|
258
264
|
ensure_open
|
|
259
265
|
cancellation&.raise_if_cancelled!
|
|
@@ -265,11 +271,13 @@ module Kward
|
|
|
265
271
|
sink: sink,
|
|
266
272
|
timeout_seconds: timeout_seconds,
|
|
267
273
|
max_output_bytes: max_output_bytes,
|
|
268
|
-
cancellation: cancellation
|
|
274
|
+
cancellation: cancellation,
|
|
275
|
+
tab_action_handler: tab_action_handler,
|
|
276
|
+
on_detach: on_detach
|
|
269
277
|
)
|
|
270
278
|
end
|
|
271
279
|
rescue CommandInterrupted => e
|
|
272
|
-
restart_process
|
|
280
|
+
restart_process unless e.protocol[:background]
|
|
273
281
|
e.protocol
|
|
274
282
|
rescue IOError
|
|
275
283
|
close
|
|
@@ -320,8 +328,8 @@ module Kward
|
|
|
320
328
|
[setup, restore]
|
|
321
329
|
end
|
|
322
330
|
|
|
323
|
-
def read_until_marker(marker, input:, sink:, timeout_seconds:, max_output_bytes:, cancellation:)
|
|
324
|
-
buffer = +"".b
|
|
331
|
+
def read_until_marker(marker, input:, sink:, timeout_seconds:, max_output_bytes:, cancellation:, tab_action_handler: nil, on_detach: nil, initial_buffer: nil)
|
|
332
|
+
buffer = initial_buffer ? initial_buffer.dup : +"".b
|
|
325
333
|
captured = +"".b
|
|
326
334
|
captured_bytes = 0
|
|
327
335
|
truncated = false
|
|
@@ -411,9 +419,45 @@ module Kward
|
|
|
411
419
|
if input_chunk.nil?
|
|
412
420
|
input = nil
|
|
413
421
|
elsif input_chunk != :wait_readable
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
422
|
+
filtered = tab_action_handler&.call(input_chunk)
|
|
423
|
+
input_chunk = filtered[:input].to_s if filtered
|
|
424
|
+
unless input_chunk.empty?
|
|
425
|
+
notify_input_forwarded(sink)
|
|
426
|
+
write_raw(input_chunk)
|
|
427
|
+
input_forwarded = true
|
|
428
|
+
end
|
|
429
|
+
if filtered && filtered[:tab_action]
|
|
430
|
+
if on_detach
|
|
431
|
+
detached_sink = on_detach.call
|
|
432
|
+
detached = detach_protocol(
|
|
433
|
+
marker,
|
|
434
|
+
sink: detached_sink,
|
|
435
|
+
timeout_seconds: timeout_seconds,
|
|
436
|
+
max_output_bytes: max_output_bytes,
|
|
437
|
+
initial_buffer: buffer,
|
|
438
|
+
input_forwarded: input_forwarded
|
|
439
|
+
)
|
|
440
|
+
raise CommandInterrupted.new(
|
|
441
|
+
output: captured,
|
|
442
|
+
status: 0,
|
|
443
|
+
cwd: @cwd,
|
|
444
|
+
input_forwarded: input_forwarded,
|
|
445
|
+
tab_action: filtered[:tab_action],
|
|
446
|
+
truncated: truncated,
|
|
447
|
+
background: detached
|
|
448
|
+
)
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
interrupt_process
|
|
452
|
+
raise CommandInterrupted.new(
|
|
453
|
+
output: captured,
|
|
454
|
+
status: 130,
|
|
455
|
+
cwd: @cwd,
|
|
456
|
+
input_forwarded: input_forwarded,
|
|
457
|
+
tab_action: filtered[:tab_action],
|
|
458
|
+
truncated: truncated
|
|
459
|
+
)
|
|
460
|
+
end
|
|
417
461
|
end
|
|
418
462
|
end
|
|
419
463
|
update_window_size
|
|
@@ -422,6 +466,30 @@ module Kward
|
|
|
422
466
|
raise IOError, "The embedded shell exited before completing the command."
|
|
423
467
|
end
|
|
424
468
|
|
|
469
|
+
def detach_protocol(marker, sink:, timeout_seconds:, max_output_bytes:, initial_buffer:, input_forwarded:)
|
|
470
|
+
DetachedRun.new(sink: sink, canceler: method(:interrupt_process)) do
|
|
471
|
+
begin
|
|
472
|
+
protocol = read_until_marker(
|
|
473
|
+
marker,
|
|
474
|
+
input: nil,
|
|
475
|
+
sink: sink,
|
|
476
|
+
timeout_seconds: timeout_seconds,
|
|
477
|
+
max_output_bytes: max_output_bytes,
|
|
478
|
+
cancellation: nil,
|
|
479
|
+
initial_buffer: initial_buffer
|
|
480
|
+
)
|
|
481
|
+
update_cwd(protocol[:cwd])
|
|
482
|
+
InteractiveResult.new(
|
|
483
|
+
exit_status: protocol[:status] || 1,
|
|
484
|
+
input_forwarded: input_forwarded || protocol[:input_forwarded],
|
|
485
|
+
background: nil
|
|
486
|
+
)
|
|
487
|
+
ensure
|
|
488
|
+
sink.finish if sink.respond_to?(:finish)
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
end
|
|
492
|
+
|
|
425
493
|
def extract_marker(buffer, marker)
|
|
426
494
|
match = buffer.match(/#{Regexp.escape(marker)}:(-?\d+):(.*?)\r?\n/m)
|
|
427
495
|
return nil unless match
|
|
@@ -494,10 +562,10 @@ module Kward
|
|
|
494
562
|
append_output_newline(output)
|
|
495
563
|
status = protocol[:status] || 1
|
|
496
564
|
if protocol[:timed_out]
|
|
497
|
-
output << "
|
|
565
|
+
output << "kwsh: command timed out after #{effective_timeout(timeout_seconds)} seconds\n"
|
|
498
566
|
status = 1
|
|
499
567
|
elsif protocol[:truncated]
|
|
500
|
-
output << "
|
|
568
|
+
output << "kwsh: output exceeded #{@max_output_bytes} bytes; output truncated\n"
|
|
501
569
|
status = 1
|
|
502
570
|
elsif cancellation&.cancelled?
|
|
503
571
|
output << "^C\n" unless output.end_with?("^C\n")
|
|
@@ -509,6 +577,31 @@ module Kward
|
|
|
509
577
|
Result.new(output: output, exit_status: status, streamed: block_given?)
|
|
510
578
|
end
|
|
511
579
|
|
|
580
|
+
def source_result(command, words)
|
|
581
|
+
unless words.length == 2
|
|
582
|
+
return Result.new(output: "#{command_echo(command)}Usage: source <file>\n", exit_status: 2)
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
path = Kwshrc.resolve_path(words[1], cwd: @cwd, env: @env)
|
|
586
|
+
config = Kwshrc.read_file(path, env: @env)
|
|
587
|
+
exports = config[:env]
|
|
588
|
+
if exports.any?
|
|
589
|
+
assignments = exports.map { |key, value| "#{key}=#{Shellwords.escape(value)}" }
|
|
590
|
+
protocol = execute_protocol("export #{assignments.join(" ")}", timeout_seconds: STARTUP_TIMEOUT_SECONDS, max_output_bytes: nil)
|
|
591
|
+
unless protocol[:status].to_i.zero?
|
|
592
|
+
return Result.new(output: "#{command_echo(command)}kwsh: source: could not apply environment\n", exit_status: 1)
|
|
593
|
+
end
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
@env.merge!(exports)
|
|
597
|
+
@aliases.merge!(config[:aliases])
|
|
598
|
+
@routing_shell = build_routing_shell
|
|
599
|
+
@completion_shell = nil
|
|
600
|
+
Result.new(output: command_echo(command), exit_status: 0)
|
|
601
|
+
rescue Kwshrc::ParseError => e
|
|
602
|
+
Result.new(output: "#{command_echo(command)}kwsh: source: #{e.message}\n", exit_status: 1)
|
|
603
|
+
end
|
|
604
|
+
|
|
512
605
|
def capture_result(command, expanded_command, cancellation: nil, &block)
|
|
513
606
|
captured_command = expanded_command.sub(/\A\s*capture(?:\s+|\z)/, "")
|
|
514
607
|
if captured_command.empty?
|
|
@@ -535,7 +628,7 @@ module Kward
|
|
|
535
628
|
|
|
536
629
|
def exit_result(command, words)
|
|
537
630
|
if words.length > 2 || (words[1] && !words[1].match?(/\A\d+\z/))
|
|
538
|
-
return Result.new(output: "#{command_echo(command)}
|
|
631
|
+
return Result.new(output: "#{command_echo(command)}kwsh: #{words.first}: numeric status expected\n", exit_status: 2)
|
|
539
632
|
end
|
|
540
633
|
|
|
541
634
|
Result.new(output: command_echo(command), exit_status: words[1].to_i, exit_shell: true)
|
|
@@ -555,7 +648,7 @@ module Kward
|
|
|
555
648
|
before = @aliases.dup
|
|
556
649
|
words.drop(1).each do |assignment|
|
|
557
650
|
name, value = assignment.split("=", 2)
|
|
558
|
-
@aliases[name] = value.to_s if value &&
|
|
651
|
+
@aliases[name] = value.to_s if value && Kwsh.valid_alias_name?(name)
|
|
559
652
|
end
|
|
560
653
|
before != @aliases
|
|
561
654
|
when "unalias"
|
|
@@ -16,6 +16,8 @@ module Kward
|
|
|
16
16
|
|
|
17
17
|
Use prepare_shell_command when the user asks you to suggest or prepare a command. It places the complete command in the shell prompt but does not execute it; the user must press Enter. Never claim that a prepared command was executed.
|
|
18
18
|
|
|
19
|
+
When available, use open_editor if the user explicitly asks to open an existing workspace file in Kward's integrated editor. Opening the editor does not modify or save the file.
|
|
20
|
+
|
|
19
21
|
Do not execute interactive commands, request passwords, or silently run commands that the user did not explicitly request. If a command needs terminal input, prepare it for the user instead.
|
|
20
22
|
PROMPT
|
|
21
23
|
}
|