kward 0.80.1 → 0.82.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 +62 -0
- data/Gemfile.lock +2 -2
- data/README.md +2 -2
- data/Rakefile +44 -4
- data/doc/composer.md +6 -9
- data/doc/configuration.md +13 -3
- data/doc/files.md +8 -2
- data/doc/permissions.md +1 -1
- data/doc/releasing.md +71 -38
- 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/kward.gemspec +1 -1
- data/lib/kward/adaptive_pty_output_sink.rb +183 -0
- data/lib/kward/ansi.rb +9 -1
- data/lib/kward/cli/commands.rb +7 -0
- data/lib/kward/cli/git.rb +5 -1
- 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 +41 -3
- data/lib/kward/cli/runtime_helpers.rb +222 -21
- data/lib/kward/cli/sessions.rb +6 -4
- data/lib/kward/cli/settings.rb +5 -13
- data/lib/kward/cli/slash_commands.rb +6 -0
- data/lib/kward/cli/tabs.rb +62 -5
- data/lib/kward/cli.rb +21 -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/image_attachments.rb +98 -15
- data/lib/kward/interactive_pty_runner.rb +102 -30
- data/lib/kward/prompt_interface/composer_controller.rb +15 -3
- data/lib/kward/prompt_interface/composer_renderer.rb +27 -0
- data/lib/kward/prompt_interface/editor/controller.rb +10 -6
- data/lib/kward/prompt_interface/editor/modes/emacs.rb +2 -2
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
- data/lib/kward/prompt_interface/git_prompt.rb +1 -1
- data/lib/kward/prompt_interface/key_handler.rb +100 -43
- data/lib/kward/prompt_interface/overlay_renderer.rb +13 -0
- data/lib/kward/prompt_interface/project_browser.rb +162 -3
- data/lib/kward/prompt_interface/prompt_renderer.rb +15 -6
- data/lib/kward/prompt_interface/question_prompt.rb +1 -1
- data/lib/kward/prompt_interface/screen.rb +40 -15
- data/lib/kward/prompt_interface/selection_prompt.rb +5 -5
- data/lib/kward/prompt_interface/transcript_renderer.rb +4 -4
- data/lib/kward/prompt_interface.rb +167 -44
- data/lib/kward/prompts/commands.rb +2 -0
- data/lib/kward/prompts.rb +6 -6
- data/lib/kward/pty_output_sink.rb +45 -0
- data/lib/kward/pty_transcript_normalizer.rb +93 -0
- data/lib/kward/rpc/server.rb +1 -0
- data/lib/kward/session_catalog.rb +87 -0
- data/lib/kward/session_store.rb +97 -7
- 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/terminal_image_support.rb +116 -0
- data/lib/kward/terminal_sequences.rb +43 -0
- data/lib/kward/version.rb +1 -1
- metadata +11 -4
- data/.github/workflows/ci.yml +0 -48
- data/.github/workflows/pages.yml +0 -48
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
require_relative "terminal_sequences"
|
|
2
|
+
|
|
3
|
+
# Namespace for the Kward CLI agent runtime.
|
|
4
|
+
module Kward
|
|
5
|
+
# Streams a conservative subset of PTY output into Kward's inline terminal
|
|
6
|
+
# region and permanently switches to exclusive passthrough before forwarding
|
|
7
|
+
# screen-oriented or unknown terminal controls.
|
|
8
|
+
class AdaptivePtyOutputSink
|
|
9
|
+
MAX_SEQUENCE_BYTES = 4096
|
|
10
|
+
SAFE_CONTROLS = [0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d].freeze
|
|
11
|
+
TERMINAL_STRING_INTRODUCERS = [0x5d, 0x50, 0x5e, 0x5f, 0x58].freeze
|
|
12
|
+
|
|
13
|
+
attr_reader :captured_output
|
|
14
|
+
|
|
15
|
+
def initialize(output:, on_exclusive:, max_capture_bytes: nil)
|
|
16
|
+
@output = output
|
|
17
|
+
@on_exclusive = on_exclusive
|
|
18
|
+
@max_capture_bytes = max_capture_bytes
|
|
19
|
+
@captured_output = max_capture_bytes ? +"".b : nil
|
|
20
|
+
@capture_open = true
|
|
21
|
+
@truncated = false
|
|
22
|
+
@mode = :inline
|
|
23
|
+
@sequence = +"".b
|
|
24
|
+
@synchronized_output = false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def write(chunk)
|
|
28
|
+
value = chunk.to_s.b
|
|
29
|
+
capture(value)
|
|
30
|
+
return @output.write(value) if exclusive?
|
|
31
|
+
|
|
32
|
+
write_inline(value)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def flush
|
|
36
|
+
@output.flush if @output.respond_to?(:flush)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def finish
|
|
40
|
+
switch_to_exclusive(@sequence) unless @sequence.empty? || exclusive?
|
|
41
|
+
end_synchronized_output if inline?
|
|
42
|
+
flush
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def input_forwarded
|
|
46
|
+
@capture_open = false
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def inline?
|
|
50
|
+
@mode == :inline
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def transcript_safe?
|
|
54
|
+
inline? && @sequence.empty?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def pre_input_capture_only?
|
|
58
|
+
true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def truncated?
|
|
62
|
+
@truncated
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def exclusive?
|
|
68
|
+
@mode == :exclusive
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def write_inline(value)
|
|
72
|
+
safe_output = +"".b
|
|
73
|
+
index = 0
|
|
74
|
+
while index < value.bytesize
|
|
75
|
+
byte = value.getbyte(index)
|
|
76
|
+
if @sequence.empty?
|
|
77
|
+
if byte == 0x1b
|
|
78
|
+
@sequence << byte
|
|
79
|
+
elsif byte >= 0x20 || SAFE_CONTROLS.include?(byte)
|
|
80
|
+
safe_output << byte
|
|
81
|
+
else
|
|
82
|
+
flush_safe_output(safe_output)
|
|
83
|
+
switch_to_exclusive(value.byteslice(index..))
|
|
84
|
+
return
|
|
85
|
+
end
|
|
86
|
+
else
|
|
87
|
+
@sequence << byte
|
|
88
|
+
status = sequence_status
|
|
89
|
+
if status == :safe
|
|
90
|
+
track_safe_sequence(@sequence)
|
|
91
|
+
safe_output << @sequence
|
|
92
|
+
@sequence.clear
|
|
93
|
+
elsif status == :exclusive
|
|
94
|
+
flush_safe_output(safe_output)
|
|
95
|
+
remainder = value.byteslice((index + 1)..).to_s.b
|
|
96
|
+
switch_to_exclusive(@sequence + remainder)
|
|
97
|
+
@sequence.clear
|
|
98
|
+
return
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
index += 1
|
|
102
|
+
end
|
|
103
|
+
flush_safe_output(safe_output)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def sequence_status
|
|
107
|
+
return :exclusive if @sequence.bytesize > MAX_SEQUENCE_BYTES
|
|
108
|
+
return :pending if @sequence.bytesize == 1
|
|
109
|
+
|
|
110
|
+
second = @sequence.getbyte(1)
|
|
111
|
+
return csi_status if second == "[".ord
|
|
112
|
+
return :exclusive if TERMINAL_STRING_INTRODUCERS.include?(second)
|
|
113
|
+
return escape_intermediate_status if second.between?(0x20, 0x2f)
|
|
114
|
+
|
|
115
|
+
:exclusive
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def csi_status
|
|
119
|
+
return :pending if @sequence.bytesize == 2
|
|
120
|
+
|
|
121
|
+
byte = @sequence.getbyte(-1)
|
|
122
|
+
return safe_csi? ? :safe : :exclusive if byte.between?(0x40, 0x7e)
|
|
123
|
+
return :pending if byte.between?(0x20, 0x3f)
|
|
124
|
+
|
|
125
|
+
:exclusive
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def escape_intermediate_status
|
|
129
|
+
byte = @sequence.getbyte(-1)
|
|
130
|
+
return :pending if byte.between?(0x20, 0x2f)
|
|
131
|
+
|
|
132
|
+
:exclusive
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def safe_csi?
|
|
136
|
+
value = @sequence
|
|
137
|
+
value.match?(/\A\e\[[0-9:;]*m\z/) ||
|
|
138
|
+
value.match?(/\A\e\[[0-2]?K\z/) ||
|
|
139
|
+
value.match?(/\A\e\[[0-9;]*[CDG`]\z/) ||
|
|
140
|
+
value.match?(/\A\e\[\?25[hl]\z/) ||
|
|
141
|
+
value.match?(/\A\e\[\?(?:2004|2026)[hl]\z/) ||
|
|
142
|
+
value.match?(/\A\e\[[0-9;]* q\z/)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def track_safe_sequence(sequence)
|
|
146
|
+
@synchronized_output = true if sequence == TerminalSequences::SYNCHRONIZED_OUTPUT_ENABLE
|
|
147
|
+
@synchronized_output = false if sequence == TerminalSequences::SYNCHRONIZED_OUTPUT_DISABLE
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def end_synchronized_output
|
|
151
|
+
return unless @synchronized_output
|
|
152
|
+
|
|
153
|
+
@output.write(TerminalSequences::SYNCHRONIZED_OUTPUT_DISABLE)
|
|
154
|
+
@synchronized_output = false
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def flush_safe_output(value)
|
|
158
|
+
return if value.empty?
|
|
159
|
+
|
|
160
|
+
@output.write(value)
|
|
161
|
+
value.clear
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def switch_to_exclusive(value)
|
|
165
|
+
end_synchronized_output
|
|
166
|
+
@on_exclusive.call
|
|
167
|
+
@mode = :exclusive
|
|
168
|
+
@output.write(value) unless value.empty?
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def capture(value)
|
|
172
|
+
return unless @captured_output && @capture_open
|
|
173
|
+
|
|
174
|
+
remaining = @max_capture_bytes - @captured_output.bytesize
|
|
175
|
+
if value.bytesize > remaining
|
|
176
|
+
@captured_output << value.byteslice(0, remaining) if remaining.positive?
|
|
177
|
+
@truncated = true
|
|
178
|
+
else
|
|
179
|
+
@captured_output << value
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
data/lib/kward/ansi.rb
CHANGED
|
@@ -67,7 +67,7 @@ module Kward
|
|
|
67
67
|
|
|
68
68
|
# Drops unsafe terminal controls from transcript text while preserving SGR color.
|
|
69
69
|
def sanitize_transcript(text)
|
|
70
|
-
scan_escape_tokens(text).each_with_object(+"") do |token, sanitized|
|
|
70
|
+
scan_escape_tokens(normalize_transcript_encoding(text)).each_with_object(+"") do |token, sanitized|
|
|
71
71
|
if token[:escape]
|
|
72
72
|
sanitized << token[:text] if token[:text].match?(SGR_PATTERN)
|
|
73
73
|
else
|
|
@@ -76,6 +76,14 @@ module Kward
|
|
|
76
76
|
end
|
|
77
77
|
end
|
|
78
78
|
|
|
79
|
+
def normalize_transcript_encoding(text)
|
|
80
|
+
string = text.to_s.dup
|
|
81
|
+
return string unless string.encoding == Encoding::ASCII_8BIT
|
|
82
|
+
|
|
83
|
+
string.force_encoding(Encoding::UTF_8)
|
|
84
|
+
string.valid_encoding? ? string : string.scrub
|
|
85
|
+
end
|
|
86
|
+
|
|
79
87
|
def wrap_visible(text, width)
|
|
80
88
|
line_width = [width.to_i, 1].max
|
|
81
89
|
rows = []
|
data/lib/kward/cli/commands.rb
CHANGED
|
@@ -54,6 +54,7 @@ module Kward
|
|
|
54
54
|
#{command.call("kward init")} Install starter prompts and PRINCIPLES.md
|
|
55
55
|
#{command.call("kward doctor")} Check local Kward setup
|
|
56
56
|
#{command.call("kward hooks doctor")} Inspect lifecycle hook setup
|
|
57
|
+
#{command.call("kward skills status")} Inspect project skill trust
|
|
57
58
|
#{command.call("kward edit")} #{option.call("<filename>")} Open a file in the integrated editor
|
|
58
59
|
#{command.call("kward sysprompt")} Inspect the effective system prompt
|
|
59
60
|
#{command.call("kward openrouter refresh")} Refresh cached OpenRouter models
|
|
@@ -69,6 +70,7 @@ module Kward
|
|
|
69
70
|
#{command.call("init")} Install starter prompts and PRINCIPLES.md
|
|
70
71
|
#{command.call("doctor")} Check local Kward setup
|
|
71
72
|
#{command.call("hooks list|events|logs|doctor|trust|untrust")} Inspect lifecycle hooks
|
|
73
|
+
#{command.call("skills status|trust|untrust|review")} Manage project skill trust
|
|
72
74
|
#{command.call("edit")} #{option.call("<filename>")} Open a file in the integrated editor
|
|
73
75
|
#{command.call("sysprompt")} [--raw] Inspect the effective system prompt
|
|
74
76
|
#{command.call("stats tokens")} [range] [options] Export local token telemetry as CSV
|
|
@@ -136,6 +138,11 @@ module Kward
|
|
|
136
138
|
description: "Inspect lifecycle hooks, recent audit records, diagnostics, and workspace hook trust.",
|
|
137
139
|
examples: ["kward hooks list", "kward hooks doctor", "kward hooks logs 50", "kward hooks trust"]
|
|
138
140
|
},
|
|
141
|
+
"skills" => {
|
|
142
|
+
usage: "kward skills [status|trust|untrust|review]",
|
|
143
|
+
description: "Inspect or manage project skill trust for the current workspace.",
|
|
144
|
+
examples: ["kward skills status", "kward skills review", "kward skills trust"]
|
|
145
|
+
},
|
|
139
146
|
"edit" => {
|
|
140
147
|
usage: "kward edit <filename>",
|
|
141
148
|
description: "Open a file in the integrated editor.",
|
data/lib/kward/cli/git.rb
CHANGED
|
@@ -30,12 +30,16 @@ module Kward
|
|
|
30
30
|
status = result.is_a?(Hash) && result.key?(:status_lines) ? result[:status_lines] : result
|
|
31
31
|
result
|
|
32
32
|
end
|
|
33
|
-
|
|
33
|
+
if message.nil?
|
|
34
|
+
refresh_composer_status
|
|
35
|
+
return
|
|
36
|
+
end
|
|
34
37
|
|
|
35
38
|
result = run_busy_local_command_and_requeue(activity: "committing") do
|
|
36
39
|
git_commit(git_root, message)
|
|
37
40
|
end
|
|
38
41
|
print_git_commit_result(result)
|
|
42
|
+
refresh_composer_status
|
|
39
43
|
ensure
|
|
40
44
|
@git_hook_conversation = previous_git_hook_conversation
|
|
41
45
|
end
|
|
@@ -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,30 @@ 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
|
+
clear_active_tab_transient_shell_output if tab && respond_to?(:clear_active_tab_transient_shell_output, true)
|
|
16
|
+
if tab&.shell
|
|
17
|
+
render_tab(tab, restore_composer: false)
|
|
18
|
+
return
|
|
19
|
+
end
|
|
20
|
+
if tab&.running?
|
|
21
|
+
@prompt.redraw if @prompt.respond_to?(:redraw)
|
|
22
|
+
return
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
conversation = tab&.agent&.conversation || @footer_conversation
|
|
26
|
+
unless conversation && @prompt.respond_to?(:restore_transcript)
|
|
27
|
+
@prompt.redraw if @prompt.respond_to?(:redraw)
|
|
28
|
+
return
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
restore_prompt_transcript do
|
|
32
|
+
tab ? render_transcript_messages(tab.driver.messages) : render_conversation_transcript(conversation)
|
|
33
|
+
render_tab_transient_shell_entries(tab) if tab && respond_to?(:render_tab_transient_shell_entries, true)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
13
37
|
# Renders a transcript supplied by either a Kward conversation or a
|
|
14
38
|
# plugin-owned tab driver.
|
|
15
39
|
def render_transcript_messages(messages)
|
|
@@ -199,7 +223,7 @@ module Kward
|
|
|
199
223
|
# Writes the user transcript output for the terminal CLI flow.
|
|
200
224
|
def print_user_transcript(input, display_input: nil, attachment_references: nil, image_parts: nil)
|
|
201
225
|
visible_input = display_input.nil? ? input : display_input
|
|
202
|
-
|
|
226
|
+
write_prompt_transcript("\n#{colored("You>", :blue, :bold)} #{visible_input}\n")
|
|
203
227
|
print_attachment_badges(input, references: attachment_references)
|
|
204
228
|
print_pasted_images(input, image_parts: image_parts)
|
|
205
229
|
end
|
|
@@ -209,7 +233,15 @@ module Kward
|
|
|
209
233
|
badges = references ? Array(references).map { |reference| attachment_badge_text(reference) } : composer_attachment_badges(input)
|
|
210
234
|
return if badges.empty?
|
|
211
235
|
|
|
212
|
-
|
|
236
|
+
write_prompt_transcript("#{badges.join("\n")}\n")
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def write_prompt_transcript(message)
|
|
240
|
+
if @prompt.respond_to?(:write_transcript)
|
|
241
|
+
@prompt.write_transcript(message)
|
|
242
|
+
else
|
|
243
|
+
@prompt.say(message)
|
|
244
|
+
end
|
|
213
245
|
end
|
|
214
246
|
|
|
215
247
|
def composer_attachment_badges(input, attachments = [])
|
|
@@ -257,8 +289,14 @@ module Kward
|
|
|
257
289
|
# Writes the pasted images output for the terminal CLI flow.
|
|
258
290
|
def print_pasted_images(input, image_parts: nil)
|
|
259
291
|
parts = image_parts || Kward::ImageAttachments.image_parts_from_text(input)
|
|
292
|
+
protocol = @prompt.inline_image_protocol if @prompt.respond_to?(:inline_image_protocol)
|
|
260
293
|
parts.each do |part|
|
|
261
|
-
|
|
294
|
+
next if @prompt.respond_to?(:inline_image_protocol) && protocol.nil?
|
|
295
|
+
|
|
296
|
+
part = Kward::ImageAttachments.terminal_image_part(part, protocol) if protocol
|
|
297
|
+
next if protocol && part.nil?
|
|
298
|
+
|
|
299
|
+
sequence = Kward::ImageAttachments.terminal_image_sequence(part, protocol: protocol)
|
|
262
300
|
next unless sequence
|
|
263
301
|
|
|
264
302
|
if @prompt.respond_to?(:say_visual)
|