kward 0.80.0 → 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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +1 -1
  5. data/doc/composer.md +5 -8
  6. data/doc/configuration.md +13 -3
  7. data/doc/permissions.md +1 -1
  8. data/doc/rpc.md +2 -1
  9. data/doc/sandboxing.md +4 -4
  10. data/doc/security.md +6 -9
  11. data/doc/shell.md +161 -196
  12. data/doc/skills.md +19 -5
  13. data/doc/tabs.md +1 -1
  14. data/doc/usage.md +6 -5
  15. data/lib/kward/agent.rb +11 -6
  16. data/lib/kward/ansi.rb +9 -1
  17. data/lib/kward/cli/commands.rb +7 -0
  18. data/lib/kward/cli/interactive_turn.rb +1 -1
  19. data/lib/kward/cli/memory_commands.rb +2 -2
  20. data/lib/kward/cli/plugins.rb +1 -1
  21. data/lib/kward/cli/project_skills.rb +99 -0
  22. data/lib/kward/cli/project_skills_commands.rb +87 -0
  23. data/lib/kward/cli/prompt_interface.rb +54 -4
  24. data/lib/kward/cli/rendering.rb +28 -2
  25. data/lib/kward/cli/runtime_helpers.rb +157 -19
  26. data/lib/kward/cli/sessions.rb +6 -4
  27. data/lib/kward/cli/settings.rb +1 -1
  28. data/lib/kward/cli/slash_commands.rb +7 -1
  29. data/lib/kward/cli/tabs.rb +4 -1
  30. data/lib/kward/cli.rb +21 -1
  31. data/lib/kward/compactor.rb +7 -2
  32. data/lib/kward/config_files.rb +30 -9
  33. data/lib/kward/conversation.rb +8 -5
  34. data/lib/kward/ekwsh.rb +37 -16
  35. data/lib/kward/hooks/audit_log.rb +5 -2
  36. data/lib/kward/interactive_pty_runner.rb +88 -22
  37. data/lib/kward/plugin_registry.rb +20 -13
  38. data/lib/kward/prompt_interface/composer_controller.rb +13 -1
  39. data/lib/kward/prompt_interface/editor/controller.rb +4 -0
  40. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +35 -10
  41. data/lib/kward/prompt_interface/key_handler.rb +78 -43
  42. data/lib/kward/prompt_interface/overlay_renderer.rb +12 -0
  43. data/lib/kward/prompt_interface/screen.rb +5 -0
  44. data/lib/kward/prompt_interface.rb +93 -21
  45. data/lib/kward/prompts/commands.rb +2 -0
  46. data/lib/kward/prompts/templates.rb +11 -6
  47. data/lib/kward/prompts.rb +6 -6
  48. data/lib/kward/rpc/server.rb +1 -0
  49. data/lib/kward/session_store.rb +3 -2
  50. data/lib/kward/skills/registry.rb +67 -12
  51. data/lib/kward/skills/trust_coordinator.rb +45 -0
  52. data/lib/kward/skills/trust_store.rb +107 -0
  53. data/lib/kward/telemetry/logger.rb +5 -2
  54. data/lib/kward/version.rb +1 -1
  55. metadata +5 -1
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 = []
@@ -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.",
@@ -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)
@@ -104,7 +104,7 @@ module Kward
104
104
  def prepare_memory_context(conversation, input)
105
105
  Memory::TurnContext.apply(conversation: conversation, input: input)
106
106
  rescue StandardError => e
107
- warn "Memory retrieval failed: #{e.message}"
107
+ emit_warning "Memory retrieval failed: #{e.message}"
108
108
  nil
109
109
  end
110
110
 
@@ -120,7 +120,7 @@ module Kward
120
120
 
121
121
  summarize_memory(conversation, manager: manager)
122
122
  rescue StandardError => e
123
- warn "Memory auto-summary failed: #{e.message}"
123
+ emit_warning "Memory auto-summary failed: #{e.message}"
124
124
  nil
125
125
  end
126
126
 
@@ -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,13 +8,16 @@ 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
 
15
15
  prompt_interface = load_prompt_interface
16
16
  return unless prompt_interface
17
17
 
18
+ @interactive_warning_sink_active = true
19
+ @interactive_warning_output_ready = !defer_warnings
20
+ ConfigFiles.warning_sink = interactive_warning_sink
18
21
  @prompt = prompt_interface.new(
19
22
  slash_commands: slash_command_entries,
20
23
  overlay_settings: ConfigFiles.overlay_settings,
@@ -41,13 +44,41 @@ module Kward
41
44
  editor_line_numbers: ConfigFiles.editor_line_numbers,
42
45
  editor_line_numbers_source: -> { ConfigFiles.editor_line_numbers },
43
46
  diff_view: ConfigFiles.diff_view,
44
- diff_view_source: -> { ConfigFiles.diff_view }
47
+ diff_view_source: -> { ConfigFiles.diff_view },
48
+ redraw_handler: method(:redraw_interactive_prompt)
45
49
  )
46
50
  if @prompt.method(:start).parameters.any? { |kind, name| [:key, :keyreq].include?(kind) && name == :render }
47
51
  @prompt.start(render: false)
48
52
  else
49
53
  @prompt.start
50
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
60
+ flush_interactive_warnings
61
+ end
62
+
63
+ def interactive_warning_sink
64
+ @interactive_warning_sink ||= lambda do |message|
65
+ unless @interactive_warning_sink_active
66
+ warn message
67
+ next
68
+ end
69
+
70
+ if @interactive_warning_output_ready && prompt_interface?
71
+ runtime_output(message)
72
+ else
73
+ (@pending_interactive_warnings ||= []) << message
74
+ end
75
+ end
76
+ end
77
+
78
+ def flush_interactive_warnings
79
+ warnings = @pending_interactive_warnings
80
+ @pending_interactive_warnings = []
81
+ Array(warnings).each { |message| runtime_output(message) }
51
82
  end
52
83
 
53
84
  def load_prompt_interface
@@ -68,6 +99,19 @@ module Kward
68
99
  @prompt.respond_to?(:start_stream_block) && @prompt.respond_to?(:write_delta)
69
100
  end
70
101
 
102
+ def emit_warning(message)
103
+ sink = ConfigFiles.warning_sink
104
+ sink ? sink.call(message) : warn(message)
105
+ end
106
+
107
+ def clear_interactive_warning_sink
108
+ ConfigFiles.warning_sink = nil
109
+ @interactive_warning_sink_active = false
110
+ @interactive_warning_output_ready = false
111
+ @pending_interactive_warnings = nil
112
+ @interactive_warning_sink = nil
113
+ end
114
+
71
115
  def update_prompt_workspace_root(root)
72
116
  return unless @prompt.respond_to?(:update_workspace_root)
73
117
 
@@ -122,10 +166,16 @@ module Kward
122
166
  end
123
167
 
124
168
  def startup_plugins_value
125
- filenames = plugin_registry.paths.map { |path| File.basename(path) }
169
+ filenames = plugin_registry.paths.map { |path| startup_plugin_name(path) }
126
170
  filenames.empty? ? "none" : filenames.join(", ")
127
171
  end
128
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
+
129
179
  def startup_status_line(refresh_update_check: false)
130
180
  color = startup_update_notice(refresh: refresh_update_check) ? :yellow : :green
131
181
  "#{ANSI.colorize("●", color, enabled: @color_enabled)} Kward v#{Kward::VERSION} is online."
@@ -179,7 +229,7 @@ module Kward
179
229
  context = plugin_context(current_footer_conversation, "")
180
230
  renderer.call(context).to_s
181
231
  rescue StandardError => e
182
- warn "Warning: Kward plugin footer error: #{e.message}"
232
+ emit_warning "Warning: Kward plugin footer error: #{e.message}"
183
233
  ""
184
234
  end
185
235
  end
@@ -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
- @prompt.say("\n#{colored("You>", :blue, :bold)} #{visible_input}\n")
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
- @prompt.say("#{badges.join("\n")}\n")
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 = [])