kward 0.80.1 → 0.81.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 +33 -0
- data/Gemfile.lock +2 -2
- data/README.md +1 -1
- data/doc/composer.md +5 -8
- data/doc/configuration.md +13 -3
- data/doc/permissions.md +1 -1
- data/doc/rpc.md +2 -1
- data/doc/sandboxing.md +4 -4
- data/doc/security.md +6 -9
- data/doc/shell.md +161 -196
- data/doc/skills.md +17 -5
- data/doc/tabs.md +1 -1
- data/doc/usage.md +6 -5
- data/lib/kward/ansi.rb +9 -1
- data/lib/kward/cli/commands.rb +7 -0
- data/lib/kward/cli/interactive_turn.rb +1 -1
- data/lib/kward/cli/plugins.rb +1 -1
- data/lib/kward/cli/project_skills.rb +99 -0
- data/lib/kward/cli/project_skills_commands.rb +87 -0
- data/lib/kward/cli/prompt_interface.rb +18 -4
- data/lib/kward/cli/rendering.rb +28 -2
- data/lib/kward/cli/runtime_helpers.rb +156 -19
- data/lib/kward/cli/sessions.rb +6 -4
- data/lib/kward/cli/settings.rb +1 -1
- data/lib/kward/cli/slash_commands.rb +6 -0
- data/lib/kward/cli/tabs.rb +3 -1
- data/lib/kward/cli.rb +19 -1
- data/lib/kward/config_files.rb +9 -4
- data/lib/kward/conversation.rb +8 -5
- data/lib/kward/ekwsh.rb +37 -16
- data/lib/kward/interactive_pty_runner.rb +88 -22
- data/lib/kward/prompt_interface/composer_controller.rb +13 -1
- data/lib/kward/prompt_interface/editor/controller.rb +4 -0
- data/lib/kward/prompt_interface/key_handler.rb +78 -43
- data/lib/kward/prompt_interface/overlay_renderer.rb +12 -0
- data/lib/kward/prompt_interface/screen.rb +5 -0
- data/lib/kward/prompt_interface.rb +93 -21
- data/lib/kward/prompts/commands.rb +2 -0
- data/lib/kward/prompts.rb +6 -6
- data/lib/kward/rpc/server.rb +1 -0
- data/lib/kward/session_store.rb +3 -2
- data/lib/kward/skills/registry.rb +52 -2
- data/lib/kward/skills/trust_coordinator.rb +45 -0
- data/lib/kward/skills/trust_store.rb +107 -0
- data/lib/kward/version.rb +1 -1
- metadata +5 -1
|
@@ -8,7 +8,6 @@ module Kward
|
|
|
8
8
|
|
|
9
9
|
def run_interactive_turn(agent, input, display_input: nil)
|
|
10
10
|
prepare_memory_context(agent.conversation, input) if agent.respond_to?(:conversation)
|
|
11
|
-
print_user_transcript(input, display_input: display_input) if prompt_interface?
|
|
12
11
|
return run_blocking_interactive_turn(agent, input, display_input: display_input) unless prompt_interface?
|
|
13
12
|
|
|
14
13
|
queued_inputs = []
|
|
@@ -27,6 +26,7 @@ module Kward
|
|
|
27
26
|
answer = nil
|
|
28
27
|
error = nil
|
|
29
28
|
@prompt.begin_busy_input("You>") if @prompt.respond_to?(:begin_busy_input)
|
|
29
|
+
print_user_transcript(input, display_input: display_input)
|
|
30
30
|
|
|
31
31
|
worker = Thread.new do
|
|
32
32
|
options = agent_display_options(display_input)
|
data/lib/kward/cli/plugins.rb
CHANGED
|
@@ -122,7 +122,7 @@ module Kward
|
|
|
122
122
|
argument_hint: template.argument_hint
|
|
123
123
|
}
|
|
124
124
|
end
|
|
125
|
-
skill_entries = ConfigFiles.skills.map do |skill|
|
|
125
|
+
skill_entries = ConfigFiles.skills(workspace_root: current_workspace_root, project_skill_paths: @interactive_project_skill_paths || []).map do |skill|
|
|
126
126
|
{
|
|
127
127
|
name: "skill:#{skill.name}",
|
|
128
128
|
description: skill.description,
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
require "pathname"
|
|
2
|
+
require_relative "../skills/trust_coordinator"
|
|
3
|
+
require_relative "../skills/trust_store"
|
|
4
|
+
|
|
5
|
+
# Namespace for CLI orchestration helpers.
|
|
6
|
+
module Kward
|
|
7
|
+
class CLI
|
|
8
|
+
# Coordinates interactive trust decisions for project-provided Agent Skills.
|
|
9
|
+
module ProjectSkills
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def prepare_interactive_project_skills
|
|
13
|
+
return unless prompt_interface?
|
|
14
|
+
|
|
15
|
+
workspace_root = current_workspace_root
|
|
16
|
+
candidates = ConfigFiles.project_skill_candidates(workspace_root: workspace_root)
|
|
17
|
+
trust_store = Skills::TrustStore.new(config_dir: ConfigFiles.config_dir)
|
|
18
|
+
coordinator = Skills::TrustCoordinator.new(workspace_root: workspace_root, trust_store: trust_store)
|
|
19
|
+
|
|
20
|
+
if ConfigFiles.project_skills_trusted?
|
|
21
|
+
@interactive_project_skill_paths = candidates.map(&:path)
|
|
22
|
+
else
|
|
23
|
+
pending = coordinator.pending(candidates)
|
|
24
|
+
unless pending.empty?
|
|
25
|
+
decision = prompt_for_project_skill_trust(pending)
|
|
26
|
+
coordinator.record!(pending, decision)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
@interactive_project_skill_paths = coordinator.allowed_paths(candidates)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def prompt_for_project_skill_trust(candidates)
|
|
34
|
+
paths = candidates.map { |candidate| " #{relative_workspace_path(candidate.path)}" }
|
|
35
|
+
message = (["Project skills found in #{current_workspace_root}:", *paths, "", "These files contain instructions that may influence the model."]).join("\n")
|
|
36
|
+
loop do
|
|
37
|
+
choice = @prompt.select(message, ["Allow", "Deny", "Review"], title: "Trust project skills")
|
|
38
|
+
case choice.to_s.downcase
|
|
39
|
+
when "review"
|
|
40
|
+
review_project_skills(candidates)
|
|
41
|
+
when "allow"
|
|
42
|
+
return "allow"
|
|
43
|
+
else
|
|
44
|
+
return "deny"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def review_project_skills(candidates)
|
|
50
|
+
candidates.each do |candidate|
|
|
51
|
+
content = read_project_skill_for_review(candidate.path)
|
|
52
|
+
resources = project_skill_resources(candidate.path)
|
|
53
|
+
details = [
|
|
54
|
+
"Project skill: #{relative_workspace_path(candidate.path)}",
|
|
55
|
+
resources.empty? ? nil : "Referenced resources:\n#{resources.map { |path| " #{path}" }.join("\n")}",
|
|
56
|
+
"",
|
|
57
|
+
content
|
|
58
|
+
].compact.join("\n")
|
|
59
|
+
@prompt.say("\n#{ANSI.strip(details)}\n")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def read_project_skill_for_review(path)
|
|
64
|
+
return "Unable to review: file is too large." if File.size(path) > ConfigFiles::MAX_SKILL_FILE_BYTES
|
|
65
|
+
|
|
66
|
+
ANSI.strip(File.read(path, ConfigFiles::MAX_SKILL_FILE_BYTES + 1))
|
|
67
|
+
rescue StandardError => e
|
|
68
|
+
"Unable to review: #{e.message}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def project_skill_resources(path)
|
|
72
|
+
folder = File.dirname(path)
|
|
73
|
+
roots = %w[scripts references assets].map { |name| File.join(folder, name) }.select { |root| Dir.exist?(root) }
|
|
74
|
+
roots.flat_map { |root| Dir.glob(File.join(root, "**", "*")).select { |entry| File.file?(entry) } }.sort.first(200).map do |resource|
|
|
75
|
+
Pathname.new(resource).relative_path_from(Pathname.new(folder)).to_s
|
|
76
|
+
end
|
|
77
|
+
rescue StandardError
|
|
78
|
+
[]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def project_skill_paths_for(workspace_root)
|
|
82
|
+
return unless @interactive_project_skill_paths
|
|
83
|
+
return unless canonical_path(workspace_root) == canonical_path(current_workspace_root)
|
|
84
|
+
|
|
85
|
+
@interactive_project_skill_paths
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def canonical_path(path)
|
|
89
|
+
File.realpath(path)
|
|
90
|
+
rescue SystemCallError
|
|
91
|
+
File.expand_path(path)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def relative_workspace_path(path)
|
|
95
|
+
Pathname.new(File.expand_path(path)).relative_path_from(Pathname.new(File.expand_path(current_workspace_root))).to_s
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Namespace for CLI orchestration helpers.
|
|
2
|
+
module Kward
|
|
3
|
+
class CLI
|
|
4
|
+
# Handles explicit project skill trust management commands.
|
|
5
|
+
module ProjectSkillsCommands
|
|
6
|
+
def handle_project_skills_cli_command(arguments)
|
|
7
|
+
argument = Array(arguments).join(" ").strip.downcase
|
|
8
|
+
_workspace_root, candidates, coordinator = project_skill_trust_coordinator
|
|
9
|
+
if candidates.empty? && argument != "untrust"
|
|
10
|
+
@prompt.say("No project skills found in the current workspace.")
|
|
11
|
+
return
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
case argument
|
|
15
|
+
when "", "status"
|
|
16
|
+
lines = candidates.empty? ? ["No project skills found in the current workspace."] : candidates.map { |candidate| "#{relative_workspace_path(candidate.path)}: #{coordinator.decision(candidate) || "needs review"}" }
|
|
17
|
+
@prompt.say(lines.join("\n"))
|
|
18
|
+
when "trust"
|
|
19
|
+
coordinator.record!(candidates, "allow")
|
|
20
|
+
@prompt.say("Project skills trusted for the current skill snapshots.")
|
|
21
|
+
when "untrust"
|
|
22
|
+
coordinator.remove_workspace!
|
|
23
|
+
@prompt.say("Project skill trust removed for this workspace.")
|
|
24
|
+
when "review"
|
|
25
|
+
review_project_skills(candidates)
|
|
26
|
+
else
|
|
27
|
+
raise ArgumentError, "Usage: kward skills [status|trust|untrust|review]"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def handle_project_skills_command(argument)
|
|
32
|
+
case argument.to_s.strip.downcase
|
|
33
|
+
when "", "status"
|
|
34
|
+
show_project_skills_status
|
|
35
|
+
when "trust"
|
|
36
|
+
trust_current_project_skills
|
|
37
|
+
when "untrust"
|
|
38
|
+
untrust_current_project_skills
|
|
39
|
+
else
|
|
40
|
+
runtime_output("Usage: /skills [status|trust|untrust]")
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def project_skill_trust_coordinator
|
|
47
|
+
workspace_root = current_workspace_root
|
|
48
|
+
candidates = ConfigFiles.project_skill_candidates(workspace_root: workspace_root)
|
|
49
|
+
store = Skills::TrustStore.new(config_dir: ConfigFiles.config_dir)
|
|
50
|
+
[workspace_root, candidates, Skills::TrustCoordinator.new(workspace_root: workspace_root, trust_store: store)]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def show_project_skills_status
|
|
54
|
+
_workspace_root, candidates, coordinator = project_skill_trust_coordinator
|
|
55
|
+
if candidates.empty?
|
|
56
|
+
runtime_output("No project skills found in the current workspace.")
|
|
57
|
+
return
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
lines = candidates.map do |candidate|
|
|
61
|
+
status = coordinator.decision(candidate) || "needs review"
|
|
62
|
+
"#{relative_workspace_path(candidate.path)}: #{status}"
|
|
63
|
+
end
|
|
64
|
+
runtime_output(lines.join("\n"))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def trust_current_project_skills
|
|
68
|
+
_workspace_root, candidates, coordinator = project_skill_trust_coordinator
|
|
69
|
+
if candidates.empty?
|
|
70
|
+
runtime_output("No project skills found in the current workspace.")
|
|
71
|
+
return
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
coordinator.record!(candidates, "allow")
|
|
75
|
+
@interactive_project_skill_paths = coordinator.allowed_paths(candidates)
|
|
76
|
+
runtime_output("Project skills trusted for the current skill snapshots. Use /new to rebuild the agent.")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def untrust_current_project_skills
|
|
80
|
+
_workspace_root, _candidates, coordinator = project_skill_trust_coordinator
|
|
81
|
+
coordinator.remove_workspace!
|
|
82
|
+
@interactive_project_skill_paths = []
|
|
83
|
+
runtime_output("Project skill trust removed for this workspace. Use /new to rebuild the agent.")
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -8,7 +8,7 @@ module Kward
|
|
|
8
8
|
module PromptInterfaceSupport
|
|
9
9
|
private
|
|
10
10
|
|
|
11
|
-
def setup_interactive_prompt
|
|
11
|
+
def setup_interactive_prompt(defer_warnings: false)
|
|
12
12
|
return unless @stdin.tty?
|
|
13
13
|
return unless @prompt.is_a?(TTY::Prompt)
|
|
14
14
|
|
|
@@ -16,6 +16,7 @@ module Kward
|
|
|
16
16
|
return unless prompt_interface
|
|
17
17
|
|
|
18
18
|
@interactive_warning_sink_active = true
|
|
19
|
+
@interactive_warning_output_ready = !defer_warnings
|
|
19
20
|
ConfigFiles.warning_sink = interactive_warning_sink
|
|
20
21
|
@prompt = prompt_interface.new(
|
|
21
22
|
slash_commands: slash_command_entries,
|
|
@@ -43,13 +44,19 @@ module Kward
|
|
|
43
44
|
editor_line_numbers: ConfigFiles.editor_line_numbers,
|
|
44
45
|
editor_line_numbers_source: -> { ConfigFiles.editor_line_numbers },
|
|
45
46
|
diff_view: ConfigFiles.diff_view,
|
|
46
|
-
diff_view_source: -> { ConfigFiles.diff_view }
|
|
47
|
+
diff_view_source: -> { ConfigFiles.diff_view },
|
|
48
|
+
redraw_handler: method(:redraw_interactive_prompt)
|
|
47
49
|
)
|
|
48
50
|
if @prompt.method(:start).parameters.any? { |kind, name| [:key, :keyreq].include?(kind) && name == :render }
|
|
49
51
|
@prompt.start(render: false)
|
|
50
52
|
else
|
|
51
53
|
@prompt.start
|
|
52
54
|
end
|
|
55
|
+
flush_interactive_warnings if @interactive_warning_output_ready
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def enable_interactive_warnings
|
|
59
|
+
@interactive_warning_output_ready = true
|
|
53
60
|
flush_interactive_warnings
|
|
54
61
|
end
|
|
55
62
|
|
|
@@ -60,7 +67,7 @@ module Kward
|
|
|
60
67
|
next
|
|
61
68
|
end
|
|
62
69
|
|
|
63
|
-
if prompt_interface?
|
|
70
|
+
if @interactive_warning_output_ready && prompt_interface?
|
|
64
71
|
runtime_output(message)
|
|
65
72
|
else
|
|
66
73
|
(@pending_interactive_warnings ||= []) << message
|
|
@@ -100,6 +107,7 @@ module Kward
|
|
|
100
107
|
def clear_interactive_warning_sink
|
|
101
108
|
ConfigFiles.warning_sink = nil
|
|
102
109
|
@interactive_warning_sink_active = false
|
|
110
|
+
@interactive_warning_output_ready = false
|
|
103
111
|
@pending_interactive_warnings = nil
|
|
104
112
|
@interactive_warning_sink = nil
|
|
105
113
|
end
|
|
@@ -158,10 +166,16 @@ module Kward
|
|
|
158
166
|
end
|
|
159
167
|
|
|
160
168
|
def startup_plugins_value
|
|
161
|
-
filenames = plugin_registry.paths.map { |path|
|
|
169
|
+
filenames = plugin_registry.paths.map { |path| startup_plugin_name(path) }
|
|
162
170
|
filenames.empty? ? "none" : filenames.join(", ")
|
|
163
171
|
end
|
|
164
172
|
|
|
173
|
+
def startup_plugin_name(path)
|
|
174
|
+
plugin_root = ConfigFiles.plugin_dir
|
|
175
|
+
prefix = "#{plugin_root}#{File::SEPARATOR}"
|
|
176
|
+
path.start_with?(prefix) ? path.delete_prefix(prefix) : File.basename(path)
|
|
177
|
+
end
|
|
178
|
+
|
|
165
179
|
def startup_status_line(refresh_update_check: false)
|
|
166
180
|
color = startup_update_notice(refresh: refresh_update_check) ? :yellow : :green
|
|
167
181
|
"#{ANSI.colorize("●", color, enabled: @color_enabled)} Kward v#{Kward::VERSION} is online."
|
data/lib/kward/cli/rendering.rb
CHANGED
|
@@ -10,6 +10,24 @@ module Kward
|
|
|
10
10
|
render_transcript_messages(conversation.messages)
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
+
def redraw_interactive_prompt
|
|
14
|
+
tab = active_tab if respond_to?(:active_tab, true)
|
|
15
|
+
if tab && (tab.running? || tab.shell)
|
|
16
|
+
@prompt.redraw if @prompt.respond_to?(:redraw)
|
|
17
|
+
return
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
conversation = tab&.agent&.conversation || @footer_conversation
|
|
21
|
+
unless conversation && @prompt.respond_to?(:restore_transcript)
|
|
22
|
+
@prompt.redraw if @prompt.respond_to?(:redraw)
|
|
23
|
+
return
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
restore_prompt_transcript do
|
|
27
|
+
tab ? render_transcript_messages(tab.driver.messages) : render_conversation_transcript(conversation)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
13
31
|
# Renders a transcript supplied by either a Kward conversation or a
|
|
14
32
|
# plugin-owned tab driver.
|
|
15
33
|
def render_transcript_messages(messages)
|
|
@@ -199,7 +217,7 @@ module Kward
|
|
|
199
217
|
# Writes the user transcript output for the terminal CLI flow.
|
|
200
218
|
def print_user_transcript(input, display_input: nil, attachment_references: nil, image_parts: nil)
|
|
201
219
|
visible_input = display_input.nil? ? input : display_input
|
|
202
|
-
|
|
220
|
+
write_prompt_transcript("\n#{colored("You>", :blue, :bold)} #{visible_input}\n")
|
|
203
221
|
print_attachment_badges(input, references: attachment_references)
|
|
204
222
|
print_pasted_images(input, image_parts: image_parts)
|
|
205
223
|
end
|
|
@@ -209,7 +227,15 @@ module Kward
|
|
|
209
227
|
badges = references ? Array(references).map { |reference| attachment_badge_text(reference) } : composer_attachment_badges(input)
|
|
210
228
|
return if badges.empty?
|
|
211
229
|
|
|
212
|
-
|
|
230
|
+
write_prompt_transcript("#{badges.join("\n")}\n")
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def write_prompt_transcript(message)
|
|
234
|
+
if @prompt.respond_to?(:write_transcript)
|
|
235
|
+
@prompt.write_transcript(message)
|
|
236
|
+
else
|
|
237
|
+
@prompt.say(message)
|
|
238
|
+
end
|
|
213
239
|
end
|
|
214
240
|
|
|
215
241
|
def composer_attachment_badges(input, attachments = [])
|
|
@@ -4,10 +4,21 @@ module Kward
|
|
|
4
4
|
class CLI
|
|
5
5
|
# Shared runtime construction helpers for CLI conversations, workspaces, plugins, and sessions.
|
|
6
6
|
module RuntimeHelpers
|
|
7
|
+
MAX_TRANSIENT_TERMINAL_OUTPUT_BYTES = 1_048_576
|
|
8
|
+
UNSAFE_TRANSCRIPT_CONTROL_PATTERN = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/.freeze
|
|
9
|
+
FROZEN_COMPOSER_COMMANDS = /\Agit\s+(?:fetch|ls-remote|push|remote|status)(?:\s|\z)/.freeze
|
|
10
|
+
|
|
7
11
|
private
|
|
8
12
|
|
|
9
13
|
def new_conversation(workspace_root: current_workspace_root)
|
|
10
|
-
Conversation.new(
|
|
14
|
+
Conversation.new(
|
|
15
|
+
workspace_root: workspace_root,
|
|
16
|
+
provider: current_model_provider,
|
|
17
|
+
model: current_model_id,
|
|
18
|
+
reasoning_effort: current_reasoning_effort,
|
|
19
|
+
plugin_registry: plugin_registry,
|
|
20
|
+
project_skill_paths: project_skill_paths_for(workspace_root)
|
|
21
|
+
)
|
|
11
22
|
end
|
|
12
23
|
|
|
13
24
|
def update_assistant_prompt(conversation)
|
|
@@ -50,6 +61,7 @@ module Kward
|
|
|
50
61
|
tool_registry = ToolRegistry.new(
|
|
51
62
|
workspace: workspace,
|
|
52
63
|
prompt: @prompt,
|
|
64
|
+
skills: ConfigFiles.skills(workspace_root: conversation.workspace_root, project_skill_paths: project_skill_paths_for(conversation.workspace_root)),
|
|
53
65
|
tool_approval: interactive_tool_approval_callback,
|
|
54
66
|
hook_manager: hook_manager,
|
|
55
67
|
hook_context: hook_context
|
|
@@ -86,17 +98,90 @@ module Kward
|
|
|
86
98
|
return true
|
|
87
99
|
end
|
|
88
100
|
|
|
101
|
+
shell = bang_shell(agent)
|
|
102
|
+
editor_result = shell.editor_command_result(command)
|
|
103
|
+
if editor_result
|
|
104
|
+
@prompt.say(editor_result.output) unless editor_result.output.to_s.empty?
|
|
105
|
+
open_ekwsh_editor(editor_result.open_editor_path, shell) if editor_result.open_editor_path
|
|
106
|
+
return true
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
expanded_command = shell.expand_alias(command, interactive: true)
|
|
110
|
+
run_user_interactive_pty_command(
|
|
111
|
+
expanded_command,
|
|
112
|
+
shell: Ekwsh::DEFAULT_SHELL,
|
|
113
|
+
env: interactive_pty_environment({}, preserve_git_pager: true),
|
|
114
|
+
cwd: interactive_workspace_root(agent),
|
|
115
|
+
intro: "$ #{command}\n"
|
|
116
|
+
)
|
|
117
|
+
true
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def run_captured_shell_command(command, agent)
|
|
121
|
+
command = command.to_s.strip
|
|
122
|
+
if command.empty?
|
|
123
|
+
runtime_output("Usage: /capture <command>")
|
|
124
|
+
return
|
|
125
|
+
end
|
|
126
|
+
|
|
89
127
|
run_busy_local_command_and_requeue(activity: "running") do
|
|
90
|
-
result =
|
|
128
|
+
result = Workspace.new(root: interactive_workspace_root(agent)).run_shell_command(command)
|
|
91
129
|
@prompt.say("\n#{colored("Shell>", :cyan, :bold)} #{command}\n#{result}\n")
|
|
92
130
|
end
|
|
93
|
-
true
|
|
94
131
|
end
|
|
95
132
|
|
|
96
133
|
def shell_command_input?(input)
|
|
97
134
|
input.to_s.start_with?("!")
|
|
98
135
|
end
|
|
99
136
|
|
|
137
|
+
def complete_bang_command(input, cursor, agent)
|
|
138
|
+
value = input.to_s
|
|
139
|
+
return false unless value.start_with?("!")
|
|
140
|
+
return nil if cursor.to_i <= 1
|
|
141
|
+
|
|
142
|
+
completion = bang_shell(agent).complete(value[1..], cursor.to_i - 1)
|
|
143
|
+
return nil unless completion
|
|
144
|
+
|
|
145
|
+
Ekwsh::Completion.new(
|
|
146
|
+
range: (completion.range.begin + 1)...(completion.range.end + 1),
|
|
147
|
+
replacement: completion.replacement,
|
|
148
|
+
candidates: completion.candidates
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def bang_shell(agent)
|
|
153
|
+
root = File.expand_path(interactive_workspace_root(agent).to_s)
|
|
154
|
+
aliases = ConfigFiles.read_ekwsh_config[:aliases]
|
|
155
|
+
cache_key = [root, ENV.fetch("PATH", ""), aliases.sort]
|
|
156
|
+
return @bang_shell if @bang_shell_key == cache_key
|
|
157
|
+
|
|
158
|
+
@bang_shell_key = cache_key
|
|
159
|
+
@bang_shell = Ekwsh.new(cwd: root, env: ENV.to_h, aliases: aliases)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def install_bang_completion_provider(agent)
|
|
163
|
+
return unless @prompt.respond_to?(:update_completion_provider)
|
|
164
|
+
|
|
165
|
+
@bang_completion_provider_installed = true
|
|
166
|
+
provider = lambda do |input, cursor|
|
|
167
|
+
current_agent = if respond_to?(:active_tab, true) && active_tab&.agent
|
|
168
|
+
active_tab.agent
|
|
169
|
+
else
|
|
170
|
+
agent
|
|
171
|
+
end
|
|
172
|
+
complete_bang_command(input, cursor, current_agent)
|
|
173
|
+
end
|
|
174
|
+
@prompt.update_completion_provider(provider)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def clear_bang_completion_provider
|
|
178
|
+
return unless @bang_completion_provider_installed
|
|
179
|
+
return unless @prompt.respond_to?(:update_completion_provider)
|
|
180
|
+
|
|
181
|
+
@prompt.update_completion_provider(nil)
|
|
182
|
+
@bang_completion_provider_installed = false
|
|
183
|
+
end
|
|
184
|
+
|
|
100
185
|
def run_ekwsh(agent)
|
|
101
186
|
unless @prompt.respond_to?(:ask)
|
|
102
187
|
runtime_output("The embedded shell is only available in interactive mode.")
|
|
@@ -140,29 +225,82 @@ module Kward
|
|
|
140
225
|
end
|
|
141
226
|
|
|
142
227
|
config = ConfigFiles.read_ekwsh_config
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
228
|
+
run_user_interactive_pty_command(
|
|
229
|
+
command,
|
|
230
|
+
shell: config[:shell],
|
|
231
|
+
env: interactive_pty_environment(config[:env]),
|
|
232
|
+
cwd: interactive_workspace_root(agent)
|
|
233
|
+
)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def run_user_interactive_pty_command(command, shell:, env:, cwd:, intro: nil)
|
|
237
|
+
intro_message = intro || "$ #{command}\n"
|
|
238
|
+
if @prompt.respond_to?(:write_transcript)
|
|
239
|
+
@prompt.write_transcript(intro_message)
|
|
240
|
+
elsif @prompt.respond_to?(:say)
|
|
241
|
+
@prompt.say(intro_message)
|
|
242
|
+
end
|
|
243
|
+
output = +"".b
|
|
244
|
+
output_truncated = false
|
|
245
|
+
handoff_options = {}
|
|
246
|
+
handoff_options[:preserve_composer] = true if frozen_composer_command?(command)
|
|
247
|
+
result = run_interactive_pty_with_terminal_handoff(shell, command, env: env, cwd: cwd, **handoff_options) do |chunk|
|
|
248
|
+
remaining = MAX_TRANSIENT_TERMINAL_OUTPUT_BYTES - output.bytesize
|
|
249
|
+
if chunk.bytesize > remaining
|
|
250
|
+
output << chunk.byteslice(0, remaining) if remaining.positive?
|
|
251
|
+
output_truncated = true
|
|
252
|
+
else
|
|
253
|
+
output << chunk
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
@prompt.refresh_composer_status if @prompt.respond_to?(:refresh_composer_status)
|
|
257
|
+
transcript_output = terminal_transcript_output(output, result, truncated: output_truncated)
|
|
258
|
+
if transcript_output && @prompt.respond_to?(:record_transient_terminal_output)
|
|
259
|
+
@prompt.record_transient_terminal_output(transcript_output)
|
|
260
|
+
end
|
|
261
|
+
result
|
|
148
262
|
rescue Errno::ENOENT => e
|
|
149
263
|
runtime_output("Error: #{e.message}")
|
|
264
|
+
nil
|
|
150
265
|
end
|
|
151
266
|
|
|
152
|
-
def run_interactive_pty_with_terminal_handoff(shell, command, env:, cwd:)
|
|
267
|
+
def run_interactive_pty_with_terminal_handoff(shell, command, env:, cwd:, preserve_composer: false, &block)
|
|
153
268
|
runner = InteractivePtyRunner.new
|
|
154
269
|
if @prompt.respond_to?(:with_terminal_handoff)
|
|
155
|
-
|
|
156
|
-
|
|
270
|
+
if preserve_composer
|
|
271
|
+
@prompt.with_terminal_handoff(preserve_composer: true) do |input, output|
|
|
272
|
+
runner.run(shell, "-c", command, env: env, cwd: cwd, input: input, output: output, &block)
|
|
273
|
+
end
|
|
274
|
+
else
|
|
275
|
+
@prompt.with_terminal_handoff do |input, output|
|
|
276
|
+
runner.run(shell, "-c", command, env: env, cwd: cwd, input: input, output: output, &block)
|
|
277
|
+
end
|
|
157
278
|
end
|
|
158
279
|
else
|
|
159
|
-
runner.run(shell, "-c", command, env: env, cwd: cwd)
|
|
280
|
+
runner.run(shell, "-c", command, env: env, cwd: cwd, &block)
|
|
160
281
|
end
|
|
161
282
|
end
|
|
162
283
|
|
|
163
|
-
def
|
|
284
|
+
def frozen_composer_command?(command)
|
|
285
|
+
FROZEN_COMPOSER_COMMANDS.match?(command.to_s.strip)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def terminal_transcript_output(output, result, truncated:)
|
|
289
|
+
return if truncated || result.input_forwarded
|
|
290
|
+
|
|
291
|
+
text = ANSI.normalize_transcript_encoding(output).gsub("\r\n", "\n")
|
|
292
|
+
return if text.empty? || text.include?("\r")
|
|
293
|
+
|
|
294
|
+
sanitized = ANSI.sanitize_transcript(text)
|
|
295
|
+
return unless sanitized == text
|
|
296
|
+
return if ANSI.strip_control_sequences(text).match?(UNSAFE_TRANSCRIPT_CONTROL_PATTERN)
|
|
297
|
+
|
|
298
|
+
sanitized
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def interactive_pty_environment(configured_env, preserve_git_pager: false)
|
|
164
302
|
ENV.to_h.merge(configured_env.to_h.transform_keys(&:to_s).transform_values(&:to_s)).tap do |env|
|
|
165
|
-
env.delete("GIT_PAGER") if env["GIT_PAGER"] == "cat"
|
|
303
|
+
env.delete("GIT_PAGER") if !preserve_git_pager && env["GIT_PAGER"] == "cat"
|
|
166
304
|
env["TERM"] = "xterm-256color" if env["TERM"].to_s.empty? || env["TERM"] == "dumb"
|
|
167
305
|
end
|
|
168
306
|
end
|
|
@@ -249,14 +387,13 @@ module Kward
|
|
|
249
387
|
end
|
|
250
388
|
|
|
251
389
|
def run_ekwsh_interactive_pty_command(shell, result)
|
|
252
|
-
|
|
253
|
-
pty_result = run_interactive_pty_with_terminal_handoff(
|
|
254
|
-
shell.command_shell,
|
|
390
|
+
run_user_interactive_pty_command(
|
|
255
391
|
result.interactive_command,
|
|
392
|
+
shell: shell.command_shell,
|
|
256
393
|
env: shell.child_env(interactive: true),
|
|
257
|
-
cwd: shell.cwd
|
|
394
|
+
cwd: shell.cwd,
|
|
395
|
+
intro: result.output
|
|
258
396
|
)
|
|
259
|
-
@prompt.say("[interactive PTY session exited with status #{pty_result.exit_status}]\n") if @prompt.respond_to?(:say)
|
|
260
397
|
end
|
|
261
398
|
|
|
262
399
|
def run_ekwsh_command(shell, input)
|
data/lib/kward/cli/sessions.rb
CHANGED
|
@@ -21,7 +21,7 @@ module Kward
|
|
|
21
21
|
path = session_store.remembered_last_session_path if session_store.respond_to?(:remembered_last_session_path)
|
|
22
22
|
return nil if path.to_s.empty?
|
|
23
23
|
|
|
24
|
-
@active_session, conversation = session_store.load(path, workspace: configured_workspace(root: session_store.cwd), provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort)
|
|
24
|
+
@active_session, conversation = session_store.load(path, workspace: configured_workspace(root: session_store.cwd), provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort, project_skill_paths: project_skill_paths_for(session_store.cwd) || [])
|
|
25
25
|
reset_session_diff(@active_session.path)
|
|
26
26
|
track_session(@active_session)
|
|
27
27
|
@resumed_last_session = true
|
|
@@ -99,6 +99,7 @@ module Kward
|
|
|
99
99
|
def start_new_session(session_store)
|
|
100
100
|
return say_sessions_unavailable unless session_store
|
|
101
101
|
|
|
102
|
+
prepare_interactive_project_skills if respond_to?(:prepare_interactive_project_skills, true)
|
|
102
103
|
previous_session = @active_session
|
|
103
104
|
@active_session = track_session(session_store.create)
|
|
104
105
|
reset_session_diff
|
|
@@ -127,7 +128,7 @@ module Kward
|
|
|
127
128
|
|
|
128
129
|
def load_session(session_store, path, message: nil)
|
|
129
130
|
previous_session = @active_session
|
|
130
|
-
@active_session, conversation = session_store.load(path, workspace: configured_workspace(root: session_store.cwd), provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort)
|
|
131
|
+
@active_session, conversation = session_store.load(path, workspace: configured_workspace(root: session_store.cwd), provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort, project_skill_paths: project_skill_paths_for(session_store.cwd) || [])
|
|
131
132
|
reset_session_diff(@active_session.path)
|
|
132
133
|
track_session(@active_session)
|
|
133
134
|
cleanup_replaced_session(previous_session)
|
|
@@ -396,7 +397,8 @@ module Kward
|
|
|
396
397
|
workspace: configured_workspace(root: session_store.cwd),
|
|
397
398
|
provider: current_model_provider,
|
|
398
399
|
model: current_model_id,
|
|
399
|
-
reasoning_effort: current_reasoning_effort
|
|
400
|
+
reasoning_effort: current_reasoning_effort,
|
|
401
|
+
project_skill_paths: project_skill_paths_for(session_store.cwd) || []
|
|
400
402
|
)
|
|
401
403
|
reset_session_diff(@active_session.path)
|
|
402
404
|
track_session(@active_session)
|
|
@@ -579,7 +581,7 @@ module Kward
|
|
|
579
581
|
end
|
|
580
582
|
|
|
581
583
|
def clone_session_file_from_path(session_store, path)
|
|
582
|
-
source_session, source_conversation = session_store.load(path, workspace: configured_workspace(root: session_store.cwd), provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort)
|
|
584
|
+
source_session, source_conversation = session_store.load(path, workspace: configured_workspace(root: session_store.cwd), provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort, project_skill_paths: project_skill_paths_for(session_store.cwd) || [])
|
|
583
585
|
clone, = session_store.create_independent_from_conversation(source_conversation, parent_session: source_session)
|
|
584
586
|
clone.path
|
|
585
587
|
end
|
data/lib/kward/cli/settings.rb
CHANGED
|
@@ -32,6 +32,9 @@ module Kward
|
|
|
32
32
|
run_busy_local_command_and_requeue { activate_skill_command(argument, agent) }
|
|
33
33
|
end
|
|
34
34
|
[true, nil]
|
|
35
|
+
when "skills"
|
|
36
|
+
run_busy_local_command_and_requeue { handle_project_skills_command(argument) }
|
|
37
|
+
[true, nil]
|
|
35
38
|
when "redraw"
|
|
36
39
|
run_busy_local_command_and_requeue { @prompt.redraw if @prompt.respond_to?(:redraw) }
|
|
37
40
|
[true, nil]
|
|
@@ -47,6 +50,9 @@ module Kward
|
|
|
47
50
|
when "shell"
|
|
48
51
|
run_ekwsh(agent)
|
|
49
52
|
[true, nil]
|
|
53
|
+
when "capture"
|
|
54
|
+
run_captured_shell_command(argument, agent)
|
|
55
|
+
[true, nil]
|
|
50
56
|
when "scratchpad"
|
|
51
57
|
open_scratchpad_command(argument)
|
|
52
58
|
[true, nil]
|
data/lib/kward/cli/tabs.rb
CHANGED
|
@@ -194,7 +194,7 @@ module Kward
|
|
|
194
194
|
|
|
195
195
|
def restore_tab_session(session_store, path, workspace_root: session_store.cwd, strict: false)
|
|
196
196
|
if File.file?(path)
|
|
197
|
-
session, conversation = session_store.load(path, workspace: configured_workspace(root: workspace_root, strict: strict), provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort)
|
|
197
|
+
session, conversation = session_store.load(path, workspace: configured_workspace(root: workspace_root, strict: strict), provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort, project_skill_paths: project_skill_paths_for(workspace_root) || [])
|
|
198
198
|
return [track_session(session), conversation]
|
|
199
199
|
end
|
|
200
200
|
|
|
@@ -215,9 +215,11 @@ module Kward
|
|
|
215
215
|
git_committer = if strict
|
|
216
216
|
->(message:, paths:) { git_commit_for_agent(workspace.root.to_s, message: message, paths: paths) }
|
|
217
217
|
end
|
|
218
|
+
project_skill_paths = project_skill_paths_for(conversation.workspace_root) || []
|
|
218
219
|
tool_registry = ToolRegistry.new(
|
|
219
220
|
workspace: workspace,
|
|
220
221
|
prompt: prompt,
|
|
222
|
+
skills: ConfigFiles.skills(workspace_root: conversation.workspace_root, project_skill_paths: project_skill_paths),
|
|
221
223
|
tool_approval: tab_tool_approval_callback(prompt),
|
|
222
224
|
hook_manager: hook_manager,
|
|
223
225
|
hook_context: hook_context,
|