kward 0.71.0 → 0.72.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 +41 -1
- data/Gemfile.lock +2 -2
- data/README.md +4 -0
- data/doc/agent-tools.md +15 -6
- data/doc/authentication.md +22 -1
- data/doc/code-search.md +42 -2
- data/doc/configuration.md +106 -3
- data/doc/context-budgeting.md +136 -0
- data/doc/context-tools.md +16 -3
- data/doc/editor.md +394 -0
- data/doc/extensibility.md +16 -7
- data/doc/files.md +100 -0
- data/doc/getting-started.md +25 -18
- data/doc/git.md +122 -0
- data/doc/memory.md +24 -4
- data/doc/personas.md +34 -5
- data/doc/plugins.md +72 -1
- data/doc/releasing.md +37 -9
- data/doc/rpc.md +74 -4
- data/doc/session-management.md +35 -1
- data/doc/shell.md +286 -0
- data/doc/tabs.md +122 -0
- data/doc/troubleshooting.md +77 -1
- data/doc/usage.md +53 -7
- data/doc/web-search.md +12 -4
- data/doc/workspace-tools.md +51 -12
- data/examples/plugins/space_invaders.rb +377 -0
- data/lib/kward/agent.rb +1 -1
- data/lib/kward/cli/commands.rb +33 -2
- data/lib/kward/cli/git.rb +150 -0
- data/lib/kward/cli/interactive_turn.rb +73 -9
- data/lib/kward/cli/plugins.rb +54 -4
- data/lib/kward/cli/prompt_interface.rb +32 -1
- data/lib/kward/cli/runtime_helpers.rb +133 -3
- data/lib/kward/cli/sessions.rb +2 -2
- data/lib/kward/cli/settings.rb +218 -9
- data/lib/kward/cli/slash_commands.rb +415 -2
- data/lib/kward/cli/tabs.rb +695 -0
- data/lib/kward/cli.rb +158 -26
- data/lib/kward/config_files.rb +123 -1
- data/lib/kward/context_budget_meter.rb +44 -0
- data/lib/kward/conversation.rb +12 -4
- data/lib/kward/editor_mode.rb +25 -0
- data/lib/kward/ekwsh.rb +362 -0
- data/lib/kward/plugin_registry.rb +61 -0
- data/lib/kward/project_files.rb +52 -0
- data/lib/kward/prompt_history.rb +82 -0
- data/lib/kward/prompt_interface/composer_controller.rb +69 -1
- data/lib/kward/prompt_interface/composer_renderer.rb +109 -13
- data/lib/kward/prompt_interface/composer_state.rb +96 -27
- data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
- data/lib/kward/prompt_interface/editor/auto_indent.rb +509 -0
- data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
- data/lib/kward/prompt_interface/editor/controller.rb +1018 -0
- data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
- data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
- data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
- data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
- data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
- data/lib/kward/prompt_interface/editor/modes/modern.rb +353 -0
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +1962 -0
- data/lib/kward/prompt_interface/editor/renderer.rb +243 -0
- data/lib/kward/prompt_interface/editor/search.rb +76 -0
- data/lib/kward/prompt_interface/editor/selections.rb +120 -0
- data/lib/kward/prompt_interface/editor/state.rb +1249 -0
- data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +420 -0
- data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
- data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
- data/lib/kward/prompt_interface/file_overlay.rb +211 -0
- data/lib/kward/prompt_interface/git_prompt.rb +299 -0
- data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
- data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
- data/lib/kward/prompt_interface/interactive/state.rb +62 -0
- data/lib/kward/prompt_interface/key_handler.rb +387 -35
- data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
- data/lib/kward/prompt_interface/project_browser.rb +524 -0
- data/lib/kward/prompt_interface/question_prompt.rb +98 -50
- data/lib/kward/prompt_interface/runtime_state.rb +43 -0
- data/lib/kward/prompt_interface/screen.rb +16 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +7 -13
- data/lib/kward/prompt_interface/stream_state.rb +7 -0
- data/lib/kward/prompt_interface/transcript_buffer.rb +6 -0
- data/lib/kward/prompt_interface.rb +286 -8
- data/lib/kward/prompts/commands.rb +5 -0
- data/lib/kward/prompts.rb +2 -0
- data/lib/kward/rpc/server.rb +42 -3
- data/lib/kward/rpc/session_manager.rb +35 -47
- data/lib/kward/rpc/session_tree_rows.rb +9 -115
- data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
- data/lib/kward/session_store.rb +44 -0
- data/lib/kward/session_tree_nodes.rb +136 -0
- data/lib/kward/session_tree_renderer.rb +9 -131
- data/lib/kward/tab_store.rb +47 -0
- data/lib/kward/text_boundary.rb +25 -0
- data/lib/kward/tools/context_budget_stats.rb +54 -0
- data/lib/kward/tools/context_for_task.rb +202 -0
- data/lib/kward/tools/read_file.rb +8 -4
- data/lib/kward/tools/registry.rb +62 -16
- data/lib/kward/tools/tool_call.rb +10 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workers/git_guard.rb +68 -0
- data/lib/kward/workers/live_view.rb +49 -0
- data/lib/kward/workers/manager.rb +288 -0
- data/lib/kward/workers/store.rb +72 -0
- data/lib/kward/workers/tool_policy.rb +23 -0
- data/lib/kward/workers/worker.rb +82 -0
- data/lib/kward/workers/write_lock.rb +38 -0
- data/lib/kward/workers.rb +7 -0
- data/lib/kward/workspace.rb +110 -24
- data/templates/default/fulldoc/html/css/kward.css +107 -36
- data/templates/default/kward_navigation.rb +12 -1
- data/templates/default/layout/html/layout.erb +4 -2
- data/templates/default/layout/html/setup.rb +6 -0
- metadata +53 -1
data/lib/kward/ekwsh.rb
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
require "shellwords"
|
|
3
|
+
|
|
4
|
+
# Namespace for the Kward CLI agent runtime.
|
|
5
|
+
module Kward
|
|
6
|
+
# Kward-native embedded shell command runner.
|
|
7
|
+
class Ekwsh
|
|
8
|
+
Result = Struct.new(:output, :exit_status, :exit_shell, :clear, :open_editor_path, keyword_init: true)
|
|
9
|
+
Completion = Struct.new(:range, :replacement, :candidates, keyword_init: true)
|
|
10
|
+
BUILTINS = %w[alias cd pwd export unset clear exit logout].freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :cwd
|
|
13
|
+
|
|
14
|
+
def initialize(cwd: Dir.pwd, env: ENV.to_h, shell: ENV["SHELL"], configured_env: {}, aliases: {})
|
|
15
|
+
@cwd = File.expand_path(cwd.to_s.empty? ? Dir.pwd : cwd.to_s)
|
|
16
|
+
@previous_cwd = nil
|
|
17
|
+
@env = env.to_h.transform_keys(&:to_s).transform_values(&:to_s)
|
|
18
|
+
@env.merge!(configured_env.to_h.transform_keys(&:to_s).transform_values(&:to_s))
|
|
19
|
+
@env["PWD"] = @cwd
|
|
20
|
+
configure_rbenv_environment
|
|
21
|
+
configure_color_environment
|
|
22
|
+
@aliases = aliases.to_h.transform_keys(&:to_s).transform_values(&:to_s)
|
|
23
|
+
@shell = shell.to_s.empty? ? "/bin/sh" : shell.to_s
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def prompt_label
|
|
27
|
+
"Shell #{display_cwd} $"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def run(input)
|
|
31
|
+
command = input.to_s.strip
|
|
32
|
+
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
|
+
|
|
35
|
+
builtin_result(command) || run_expanded_command(command)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def complete(input, cursor)
|
|
39
|
+
token = completion_token(input.to_s, cursor.to_i)
|
|
40
|
+
return nil if token[:command] && token[:text].empty?
|
|
41
|
+
|
|
42
|
+
candidates = if token[:command] && !path_like_token?(token[:text])
|
|
43
|
+
command_candidates(token[:text])
|
|
44
|
+
else
|
|
45
|
+
path_candidates(token[:text], directories_only: cd_completion?(input, token))
|
|
46
|
+
end
|
|
47
|
+
return nil if candidates.empty?
|
|
48
|
+
|
|
49
|
+
replacement = completion_replacement(token[:text], candidates)
|
|
50
|
+
Completion.new(range: token[:range], replacement: replacement, candidates: candidates)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def configure_rbenv_environment
|
|
56
|
+
root = @env["RBENV_ROOT"].to_s
|
|
57
|
+
root = File.expand_path("~/.rbenv") if root.empty?
|
|
58
|
+
root = File.expand_path(root)
|
|
59
|
+
paths = [File.join(root, "shims"), File.join(root, "bin")].select { |path| Dir.exist?(path) }
|
|
60
|
+
return if paths.empty?
|
|
61
|
+
|
|
62
|
+
@env["RBENV_ROOT"] = root
|
|
63
|
+
@env["PATH"] = prepend_path_entries(@env["PATH"], paths)
|
|
64
|
+
rescue ArgumentError
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def prepend_path_entries(path, entries)
|
|
69
|
+
current = path.to_s.split(File::PATH_SEPARATOR)
|
|
70
|
+
(entries + current).uniq.join(File::PATH_SEPARATOR)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def configure_color_environment
|
|
74
|
+
@env["CLICOLOR"] ||= "1"
|
|
75
|
+
@env["COLORTERM"] ||= "truecolor"
|
|
76
|
+
@env["TERM"] = "xterm-256color" if @env["TERM"].to_s.empty? || @env["TERM"] == "dumb"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def completion_token(input, cursor)
|
|
80
|
+
cursor = [[cursor, 0].max, input.length].min
|
|
81
|
+
start_index = cursor
|
|
82
|
+
start_index -= 1 while start_index.positive? && token_character?(input, start_index - 1)
|
|
83
|
+
text = input[start_index...cursor].to_s
|
|
84
|
+
before = input[0...start_index].to_s
|
|
85
|
+
{ range: (start_index...cursor), text: text, command: before.strip.empty? }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def token_character?(input, index)
|
|
89
|
+
return true unless input[index].match?(/\s/)
|
|
90
|
+
|
|
91
|
+
escaped_character?(input, index)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def escaped_character?(input, index)
|
|
95
|
+
backslashes = 0
|
|
96
|
+
cursor = index - 1
|
|
97
|
+
while cursor >= 0 && input[cursor] == "\\"
|
|
98
|
+
backslashes += 1
|
|
99
|
+
cursor -= 1
|
|
100
|
+
end
|
|
101
|
+
backslashes.odd?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def cd_completion?(input, token)
|
|
105
|
+
input[0...token[:range].begin].to_s.strip == "cd"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def path_like_token?(text)
|
|
109
|
+
text.to_s.include?("/")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def command_candidates(prefix)
|
|
113
|
+
(BUILTINS + @aliases.keys + path_executables).uniq.grep(/\A#{Regexp.escape(prefix)}/).sort
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def path_executables
|
|
117
|
+
@env.fetch("PATH", "").split(File::PATH_SEPARATOR).flat_map do |path|
|
|
118
|
+
next [] unless File.directory?(path)
|
|
119
|
+
|
|
120
|
+
Dir.children(path).filter_map do |entry|
|
|
121
|
+
full_path = File.join(path, entry)
|
|
122
|
+
entry if File.file?(full_path) && File.executable?(full_path)
|
|
123
|
+
end
|
|
124
|
+
rescue SystemCallError
|
|
125
|
+
[]
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def path_candidates(prefix, directories_only: false)
|
|
130
|
+
raw_dir, raw_base = split_path_prefix(prefix)
|
|
131
|
+
dir = File.expand_path(unescape_path(raw_dir.empty? ? "." : raw_dir), @cwd)
|
|
132
|
+
return [] unless File.directory?(dir)
|
|
133
|
+
|
|
134
|
+
Dir.children(dir).filter_map do |entry|
|
|
135
|
+
next unless entry.start_with?(unescape_path(raw_base))
|
|
136
|
+
|
|
137
|
+
path = File.join(dir, entry)
|
|
138
|
+
directory = File.directory?(path)
|
|
139
|
+
next if directories_only && !directory
|
|
140
|
+
|
|
141
|
+
completed = "#{raw_dir}#{Shellwords.escape(entry)}"
|
|
142
|
+
completed = "#{completed}/" if directory
|
|
143
|
+
completed
|
|
144
|
+
end.sort
|
|
145
|
+
rescue SystemCallError
|
|
146
|
+
[]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def split_path_prefix(prefix)
|
|
150
|
+
index = prefix.rindex("/")
|
|
151
|
+
return ["", prefix] unless index
|
|
152
|
+
|
|
153
|
+
[prefix[0..index], prefix[(index + 1)..].to_s]
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def unescape_path(value)
|
|
157
|
+
value.to_s.gsub(/\\(.)/, "\\1")
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def completion_replacement(prefix, candidates)
|
|
161
|
+
return add_completion_suffix(candidates.first) if candidates.length == 1
|
|
162
|
+
|
|
163
|
+
common = common_prefix(candidates)
|
|
164
|
+
common.length > prefix.length ? common : prefix
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def add_completion_suffix(candidate)
|
|
168
|
+
candidate.end_with?("/") ? candidate : "#{candidate} "
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def common_prefix(values)
|
|
172
|
+
first = values.first.to_s
|
|
173
|
+
values.drop(1).reduce(first) do |prefix, value|
|
|
174
|
+
prefix = prefix[0...-1] until value.start_with?(prefix) || prefix.empty?
|
|
175
|
+
prefix
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def display_cwd
|
|
180
|
+
home = Dir.home.to_s
|
|
181
|
+
return "~" if @cwd == home
|
|
182
|
+
return "~#{@cwd.delete_prefix(home)}" if !home.empty? && @cwd.start_with?("#{home}/")
|
|
183
|
+
|
|
184
|
+
@cwd
|
|
185
|
+
rescue ArgumentError
|
|
186
|
+
@cwd
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def command_echo(command)
|
|
190
|
+
"$ #{command}\n"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def exit_command?(command)
|
|
194
|
+
["exit", "logout"].include?(command)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def builtin_result(command)
|
|
198
|
+
words = shell_words(command)
|
|
199
|
+
return nil if words.empty?
|
|
200
|
+
|
|
201
|
+
case words.first
|
|
202
|
+
when "alias"
|
|
203
|
+
list_aliases(command, words)
|
|
204
|
+
when "cd"
|
|
205
|
+
change_directory(command, words)
|
|
206
|
+
when "pwd"
|
|
207
|
+
Result.new(output: "#{command_echo(command)}#{@cwd}\n", exit_status: 0)
|
|
208
|
+
when "export"
|
|
209
|
+
export_variables(command, words)
|
|
210
|
+
when "unset"
|
|
211
|
+
unset_variables(command, words)
|
|
212
|
+
when "clear"
|
|
213
|
+
Result.new(output: "", exit_status: 0, clear: true)
|
|
214
|
+
else
|
|
215
|
+
nil
|
|
216
|
+
end
|
|
217
|
+
rescue ArgumentError => e
|
|
218
|
+
Result.new(output: "#{command_echo(command)}ekwsh: #{e.message}\n", exit_status: 2)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def shell_words(command)
|
|
222
|
+
Shellwords.shellsplit(command)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def list_aliases(command, words)
|
|
226
|
+
assignments, names = words.drop(1).partition { |word| word.include?("=") }
|
|
227
|
+
invalid = []
|
|
228
|
+
assignments.each do |assignment|
|
|
229
|
+
name, value = assignment.split("=", 2)
|
|
230
|
+
if valid_alias_name?(name)
|
|
231
|
+
@aliases[name] = value.to_s
|
|
232
|
+
else
|
|
233
|
+
invalid << name
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
return Result.new(output: "#{command_echo(command)}ekwsh: alias: invalid name: #{invalid.join(" ")}\n", exit_status: 2) unless invalid.empty?
|
|
237
|
+
|
|
238
|
+
names = @aliases.keys.sort if names.empty? && assignments.empty?
|
|
239
|
+
lines = names.filter_map { |name| @aliases[name] ? "#{name}=#{Shellwords.escape(@aliases.fetch(name))}" : nil }
|
|
240
|
+
suffix = lines.empty? ? "" : "#{lines.join("\n")}\n"
|
|
241
|
+
Result.new(output: "#{command_echo(command)}#{suffix}", exit_status: 0)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def valid_alias_name?(name)
|
|
245
|
+
name.to_s.match?(/\A[A-Za-z_][A-Za-z0-9_-]*\z/) && !BUILTINS.include?(name.to_s)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def expand_alias(command)
|
|
249
|
+
words = shell_words(command)
|
|
250
|
+
return command if words.empty? || BUILTINS.include?(words.first)
|
|
251
|
+
return command unless @aliases[words.first]
|
|
252
|
+
|
|
253
|
+
rest = command.sub(/\A\s*#{Regexp.escape(words.first)}\b\s*/, "")
|
|
254
|
+
[@aliases.fetch(words.first), rest].reject(&:empty?).join(" ")
|
|
255
|
+
rescue ArgumentError
|
|
256
|
+
command
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def run_expanded_command(command)
|
|
260
|
+
expanded_command = expand_alias(command)
|
|
261
|
+
kward_result = kward_command_result(expanded_command, display_command: command)
|
|
262
|
+
return kward_result if kward_result
|
|
263
|
+
|
|
264
|
+
execute(expanded_command, display_command: command)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def kward_command_result(command, display_command: command)
|
|
268
|
+
words = shell_words(command)
|
|
269
|
+
return nil unless kward_edit_command?(words)
|
|
270
|
+
|
|
271
|
+
unless words.length == 3
|
|
272
|
+
return Result.new(output: "#{command_echo(display_command)}Usage: kward edit <filename>\n", exit_status: 2)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
path = File.expand_path(words[2], @cwd)
|
|
276
|
+
Result.new(output: command_echo(display_command), exit_status: 0, open_editor_path: path)
|
|
277
|
+
rescue ArgumentError => e
|
|
278
|
+
Result.new(output: "#{command_echo(display_command)}ekwsh: #{e.message}\n", exit_status: 2)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def kward_edit_command?(words)
|
|
282
|
+
return false unless words[1] == "edit"
|
|
283
|
+
|
|
284
|
+
File.basename(words[0].to_s) == "kward"
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def change_directory(command, words)
|
|
288
|
+
target = words[1]
|
|
289
|
+
target = Dir.home if target.nil? || target.empty?
|
|
290
|
+
target = @previous_cwd || @cwd if target == "-"
|
|
291
|
+
path = File.expand_path(target, @cwd)
|
|
292
|
+
unless File.directory?(path)
|
|
293
|
+
return Result.new(output: "#{command_echo(command)}ekwsh: cd: no such directory: #{target}\n", exit_status: 1)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
@previous_cwd = @cwd
|
|
297
|
+
@cwd = path
|
|
298
|
+
@env["OLDPWD"] = @previous_cwd
|
|
299
|
+
@env["PWD"] = @cwd
|
|
300
|
+
output = command_echo(command)
|
|
301
|
+
output << "#{@cwd}\n" if words[1] == "-"
|
|
302
|
+
Result.new(output: output, exit_status: 0)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def export_variables(command, words)
|
|
306
|
+
if words.length == 1
|
|
307
|
+
lines = @env.keys.sort.map { |key| "export #{key}=#{Shellwords.escape(@env.fetch(key))}" }
|
|
308
|
+
return Result.new(output: "#{command_echo(command)}#{lines.join("\n")}\n", exit_status: 0)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
invalid = []
|
|
312
|
+
words.drop(1).each do |assignment|
|
|
313
|
+
key, value = assignment.split("=", 2)
|
|
314
|
+
if value.nil? || !valid_env_key?(key)
|
|
315
|
+
invalid << assignment
|
|
316
|
+
else
|
|
317
|
+
@env[key] = value
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
if invalid.empty?
|
|
322
|
+
Result.new(output: command_echo(command), exit_status: 0)
|
|
323
|
+
else
|
|
324
|
+
Result.new(output: "#{command_echo(command)}ekwsh: export: invalid assignment: #{invalid.join(" ")}\n", exit_status: 2)
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def unset_variables(command, words)
|
|
329
|
+
invalid = words.drop(1).reject { |key| valid_env_key?(key) }
|
|
330
|
+
words.drop(1).each { |key| @env.delete(key) if valid_env_key?(key) }
|
|
331
|
+
|
|
332
|
+
if invalid.empty?
|
|
333
|
+
Result.new(output: command_echo(command), exit_status: 0)
|
|
334
|
+
else
|
|
335
|
+
Result.new(output: "#{command_echo(command)}ekwsh: unset: invalid name: #{invalid.join(" ")}\n", exit_status: 2)
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def valid_env_key?(key)
|
|
340
|
+
key.to_s.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
|
|
341
|
+
end
|
|
342
|
+
|
|
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
|
|
346
|
+
output = command_echo(display_command)
|
|
347
|
+
output << clean_output(stdout)
|
|
348
|
+
output << clean_output(stderr)
|
|
349
|
+
output << "Exit status: #{exit_status}\n" unless exit_status.zero?
|
|
350
|
+
Result.new(output: output, exit_status: exit_status)
|
|
351
|
+
rescue Errno::ENOENT => e
|
|
352
|
+
Result.new(output: "#{command_echo(display_command)}ekwsh: #{e.message}\n", exit_status: 127)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def clean_output(value)
|
|
356
|
+
text = value.to_s.dup
|
|
357
|
+
text.force_encoding(Encoding::UTF_8)
|
|
358
|
+
text = text.valid_encoding? ? text : text.scrub
|
|
359
|
+
text.end_with?("\n") || text.empty? ? text : "#{text}\n"
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
end
|
|
@@ -23,6 +23,15 @@ module Kward
|
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
+
# Registered interactive command that takes over the composer region with a
|
|
27
|
+
# Kward-driven render and input loop. Like a slash command but with canvas
|
|
28
|
+
# rendering capabilities for games, dashboards, viewers, and similar uses.
|
|
29
|
+
InteractiveCommand = Struct.new(:name, :description, :argument_hint, :rows, :fps, :path, :handler, keyword_init: true) do
|
|
30
|
+
def entry
|
|
31
|
+
{ name: name, description: description, argument_hint: argument_hint }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
26
35
|
# Read-only event passed to plugin transcript observers.
|
|
27
36
|
TranscriptEvent = Struct.new(:type, :payload, keyword_init: true) do
|
|
28
37
|
def to_h
|
|
@@ -167,6 +176,24 @@ module Kward
|
|
|
167
176
|
def prompt_context(&block)
|
|
168
177
|
@registry.register_prompt_context(path: @path, &block)
|
|
169
178
|
end
|
|
179
|
+
|
|
180
|
+
# Registers an interactive command that takes over the composer region with
|
|
181
|
+
# a Kward-driven render and input loop. The handler receives an
|
|
182
|
+
# interactive controller object with a canvas API for drawing colored
|
|
183
|
+
# cells and reading keys. Useful for games, dashboards, and viewers.
|
|
184
|
+
#
|
|
185
|
+
# @param name [String, #to_s] command name without the leading slash
|
|
186
|
+
# @param rows [Integer] fixed canvas height in terminal rows
|
|
187
|
+
# @param fps [Numeric] frame rate for tick callbacks (1-120, default 30)
|
|
188
|
+
# @param description [String] short text shown in command listings
|
|
189
|
+
# @param argument_hint [String] optional usage hint for arguments
|
|
190
|
+
# @yieldparam ui [Object] interactive controller with canvas and key API
|
|
191
|
+
# @yieldparam ctx [Context] plugin execution context
|
|
192
|
+
# @return [void]
|
|
193
|
+
# @api public
|
|
194
|
+
def interactive_command(name, rows:, fps: 30, description: "", argument_hint: "", &block)
|
|
195
|
+
@registry.register_interactive_command(name, rows: rows, fps: fps, description: description, argument_hint: argument_hint, path: @path, &block)
|
|
196
|
+
end
|
|
170
197
|
end
|
|
171
198
|
|
|
172
199
|
# Mutable singleton guard used while loading trusted plugin files.
|
|
@@ -207,6 +234,7 @@ module Kward
|
|
|
207
234
|
def initialize(reserved_commands: [])
|
|
208
235
|
@reserved_commands = reserved_commands.map(&:to_s)
|
|
209
236
|
@commands = {}
|
|
237
|
+
@interactive_commands = {}
|
|
210
238
|
@footer = nil
|
|
211
239
|
@footer_path = nil
|
|
212
240
|
@transcript_event_handlers = []
|
|
@@ -228,6 +256,14 @@ module Kward
|
|
|
228
256
|
@commands[name.to_s]
|
|
229
257
|
end
|
|
230
258
|
|
|
259
|
+
def interactive_commands
|
|
260
|
+
@interactive_commands.values
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def interactive_command_for(name)
|
|
264
|
+
@interactive_commands[name.to_s]
|
|
265
|
+
end
|
|
266
|
+
|
|
231
267
|
def footer_renderer
|
|
232
268
|
@footer
|
|
233
269
|
end
|
|
@@ -306,6 +342,31 @@ module Kward
|
|
|
306
342
|
)
|
|
307
343
|
end
|
|
308
344
|
|
|
345
|
+
def register_interactive_command(name, rows:, fps: 30, description: "", argument_hint: "", path: nil, &handler)
|
|
346
|
+
name = name.to_s
|
|
347
|
+
raise "Interactive command name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
|
|
348
|
+
raise "Interactive command /#{name} requires a handler" unless handler
|
|
349
|
+
|
|
350
|
+
if @reserved_commands.include?(name) || @commands.key?(name)
|
|
351
|
+
warn "Warning: skipping Kward interactive command /#{name}: reserved command"
|
|
352
|
+
return nil
|
|
353
|
+
end
|
|
354
|
+
if @interactive_commands.key?(name)
|
|
355
|
+
warn "Warning: skipping duplicate Kward interactive command /#{name}: #{path}"
|
|
356
|
+
return nil
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
@interactive_commands[name] = InteractiveCommand.new(
|
|
360
|
+
name: name,
|
|
361
|
+
description: description.to_s,
|
|
362
|
+
argument_hint: argument_hint.to_s,
|
|
363
|
+
rows: [[rows.to_i, 1].max, 1].max,
|
|
364
|
+
fps: [[fps.to_f, 1].max, 120].min,
|
|
365
|
+
path: path,
|
|
366
|
+
handler: handler
|
|
367
|
+
)
|
|
368
|
+
end
|
|
369
|
+
|
|
309
370
|
def register_footer(path: nil, &renderer)
|
|
310
371
|
raise "Plugin footer requires a renderer" unless renderer
|
|
311
372
|
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
require "find"
|
|
2
|
+
require "open3"
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
# Namespace for the Kward CLI agent runtime.
|
|
6
|
+
module Kward
|
|
7
|
+
# Discovers project files for prompt UI features.
|
|
8
|
+
module ProjectFiles
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def list(root: Dir.pwd)
|
|
12
|
+
paths = git_paths(root)
|
|
13
|
+
paths = scanned_paths(root) if paths.empty?
|
|
14
|
+
paths.reject { |path| path.empty? || path.end_with?("/") }.uniq.sort
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def git_paths(root)
|
|
18
|
+
output, status = Open3.capture2("git", "ls-files", "--cached", "--others", "--exclude-standard", chdir: root)
|
|
19
|
+
return [] unless status.success?
|
|
20
|
+
|
|
21
|
+
output.lines.map(&:chomp).reject(&:empty?)
|
|
22
|
+
rescue StandardError
|
|
23
|
+
[]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def scanned_paths(root)
|
|
27
|
+
root_path = Pathname.new(root)
|
|
28
|
+
paths = []
|
|
29
|
+
Find.find(root_path.to_s) do |path|
|
|
30
|
+
relative = Pathname.new(path).relative_path_from(root_path).to_s
|
|
31
|
+
if File.directory?(path)
|
|
32
|
+
Find.prune if ignored_directory?(relative)
|
|
33
|
+
next
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
paths << relative unless ignored_file?(relative)
|
|
37
|
+
end
|
|
38
|
+
paths
|
|
39
|
+
rescue StandardError
|
|
40
|
+
[]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def ignored_directory?(relative)
|
|
44
|
+
ignored_directories = %w[.git .yardoc _yardoc node_modules rdoc tmp vendor/bundle]
|
|
45
|
+
ignored_directories.include?(relative) || relative.start_with?(".git/")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def ignored_file?(relative)
|
|
49
|
+
relative.start_with?(".git/")
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "json"
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "config_files"
|
|
5
|
+
|
|
6
|
+
# Namespace for the Kward CLI agent runtime.
|
|
7
|
+
module Kward
|
|
8
|
+
# Workspace-scoped JSONL persistence for terminal prompt history.
|
|
9
|
+
class PromptHistory
|
|
10
|
+
DEFAULT_LIMIT = 1_000
|
|
11
|
+
|
|
12
|
+
Entry = Struct.new(:value, :timestamp, keyword_init: true)
|
|
13
|
+
|
|
14
|
+
def initialize(config_dir: ConfigFiles.config_dir, cwd: Dir.pwd, limit: DEFAULT_LIMIT)
|
|
15
|
+
@config_dir = config_dir
|
|
16
|
+
@cwd = ConfigFiles.canonical_workspace_root(cwd)
|
|
17
|
+
@limit = limit.to_i.positive? ? limit.to_i : DEFAULT_LIMIT
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
attr_reader :cwd, :limit
|
|
21
|
+
|
|
22
|
+
def values
|
|
23
|
+
entries.map(&:value)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def append(value)
|
|
27
|
+
text = value.to_s
|
|
28
|
+
return false if text.strip.empty?
|
|
29
|
+
|
|
30
|
+
existing = entries
|
|
31
|
+
return false if existing.last&.value == text
|
|
32
|
+
|
|
33
|
+
write_entries((existing + [Entry.new(value: text, timestamp: Time.now.utc.iso8601(3))]).last(limit))
|
|
34
|
+
true
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def path
|
|
38
|
+
ConfigFiles.prompt_history_path(@cwd, config_dir: @config_dir)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def entries
|
|
44
|
+
return [] unless File.file?(path)
|
|
45
|
+
|
|
46
|
+
File.readlines(path, chomp: true).filter_map do |line|
|
|
47
|
+
record = JSON.parse(line)
|
|
48
|
+
value = record["value"].to_s
|
|
49
|
+
next if value.strip.empty?
|
|
50
|
+
|
|
51
|
+
Entry.new(value: value, timestamp: record["timestamp"].to_s)
|
|
52
|
+
rescue JSON::ParserError
|
|
53
|
+
nil
|
|
54
|
+
end.last(limit)
|
|
55
|
+
rescue Errno::ENOENT
|
|
56
|
+
[]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def write_entries(entries)
|
|
60
|
+
FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
|
|
61
|
+
File.open(path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |file|
|
|
62
|
+
file.write(JSON.generate(history_header))
|
|
63
|
+
file.write("\n")
|
|
64
|
+
entries.each do |entry|
|
|
65
|
+
file.write(JSON.generate({ type: "prompt_history", version: 1, timestamp: entry.timestamp || Time.now.utc.iso8601(3), value: entry.value }))
|
|
66
|
+
file.write("\n")
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
File.chmod(0o600, path)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def history_header
|
|
73
|
+
{
|
|
74
|
+
type: "prompt_history_header",
|
|
75
|
+
version: 1,
|
|
76
|
+
workspace: @cwd,
|
|
77
|
+
workspaceHash: File.basename(path, ".jsonl"),
|
|
78
|
+
limit: limit
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|