kward 0.83.0 → 0.85.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +102 -15
  3. data/CONTRIBUTING.md +74 -0
  4. data/Gemfile.lock +8 -2
  5. data/README.md +21 -1
  6. data/Rakefile +46 -2
  7. data/SECURITY.md +31 -0
  8. data/doc/agent-tools.md +13 -1
  9. data/doc/api.md +21 -2
  10. data/doc/composer.md +2 -2
  11. data/doc/configuration.md +95 -25
  12. data/doc/editor.md +28 -13
  13. data/doc/extensibility.md +2 -1
  14. data/doc/files.md +8 -4
  15. data/doc/getting-started.md +3 -0
  16. data/doc/git.md +3 -1
  17. data/doc/pan.md +25 -15
  18. data/doc/permissions.md +4 -4
  19. data/doc/platform-support.md +48 -0
  20. data/doc/plugins.md +464 -15
  21. data/doc/rpc.md +154 -16
  22. data/doc/sandboxing.md +11 -5
  23. data/doc/security.md +10 -3
  24. data/doc/session-management.md +5 -4
  25. data/doc/shell.md +62 -45
  26. data/doc/tabs.md +6 -2
  27. data/doc/transports.md +15 -0
  28. data/doc/troubleshooting.md +12 -2
  29. data/doc/usage.md +9 -6
  30. data/doc/workspace-tools.md +9 -0
  31. data/examples/plugins/space_invaders.rb +1 -1
  32. data/examples/plugins/stardate_footer.rb +2 -2
  33. data/examples/plugins/telegram/plugin.rb +1 -1
  34. data/kward.gemspec +5 -4
  35. data/lib/kward/agent.rb +30 -14
  36. data/lib/kward/cli/auth_commands.rb +34 -13
  37. data/lib/kward/cli/commands.rb +83 -62
  38. data/lib/kward/cli/compaction.rb +9 -3
  39. data/lib/kward/cli/doctor.rb +39 -17
  40. data/lib/kward/cli/hook_commands.rb +22 -12
  41. data/lib/kward/cli/interactive_turn.rb +48 -7
  42. data/lib/kward/cli/plugins.rb +81 -12
  43. data/lib/kward/cli/project_skills_commands.rb +8 -4
  44. data/lib/kward/cli/prompt_interface.rb +52 -5
  45. data/lib/kward/cli/rendering.rb +18 -9
  46. data/lib/kward/cli/runtime_helpers.rb +228 -71
  47. data/lib/kward/cli/sessions.rb +9 -5
  48. data/lib/kward/cli/settings/menus.rb +745 -0
  49. data/lib/kward/cli/settings/model.rb +327 -0
  50. data/lib/kward/cli/settings.rb +6 -1055
  51. data/lib/kward/cli/slash_commands.rb +56 -17
  52. data/lib/kward/cli/tabs.rb +244 -33
  53. data/lib/kward/cli/tool_summaries.rb +14 -0
  54. data/lib/kward/{cli_transcript_formatter.rb → cli/transcript_formatter.rb} +14 -7
  55. data/lib/kward/cli/worktrees.rb +65 -2
  56. data/lib/kward/cli.rb +70 -30
  57. data/lib/kward/compactor.rb +18 -7
  58. data/lib/kward/config/core.rb +389 -0
  59. data/lib/kward/config/extensions.rb +96 -0
  60. data/lib/kward/config/prompts.rb +313 -0
  61. data/lib/kward/config/settings.rb +250 -0
  62. data/lib/kward/config_files.rb +14 -994
  63. data/lib/kward/conversation.rb +31 -2
  64. data/lib/kward/image_attachments.rb +1 -1
  65. data/lib/kward/model/client.rb +36 -24
  66. data/lib/kward/model/copilot_models.rb +2 -2
  67. data/lib/kward/model/model_info.rb +20 -3
  68. data/lib/kward/{openrouter_model_cache.rb → model/openrouter_model_cache.rb} +3 -3
  69. data/lib/kward/model/payloads.rb +12 -3
  70. data/lib/kward/model/provider_catalog.rb +5 -0
  71. data/lib/kward/model/stream_parser.rb +20 -4
  72. data/lib/kward/model/typesafe_client.rb +78 -0
  73. data/lib/kward/pan/index.html.erb +3 -3
  74. data/lib/kward/pan/server.rb +33 -10
  75. data/lib/kward/permissions/policy.rb +6 -2
  76. data/lib/kward/plugin_registry.rb +2 -659
  77. data/lib/kward/plugins/actions.rb +453 -0
  78. data/lib/kward/plugins/chat_contract.rb +121 -0
  79. data/lib/kward/{plugin_chat_runtime.rb → plugins/chat_runtime.rb} +62 -18
  80. data/lib/kward/plugins/host.rb +232 -0
  81. data/lib/kward/plugins/registry.rb +1190 -0
  82. data/lib/kward/plugins/resources.rb +206 -0
  83. data/lib/kward/plugins/turn_request.rb +36 -0
  84. data/lib/kward/plugins/ui.rb +219 -0
  85. data/lib/kward/prompt_interface/composer_renderer.rb +44 -40
  86. data/lib/kward/prompt_interface/composer_state.rb +33 -24
  87. data/lib/kward/prompt_interface/editor/auto_indent.rb +24 -22
  88. data/lib/kward/prompt_interface/editor/controller.rb +30 -33
  89. data/lib/kward/prompt_interface/editor/endwise.rb +13 -4
  90. data/lib/kward/prompt_interface/editor/markdown_code_block.rb +136 -0
  91. data/lib/kward/prompt_interface/editor/modes/vibe.rb +289 -44
  92. data/lib/kward/prompt_interface/editor/renderer.rb +108 -6
  93. data/lib/kward/prompt_interface/editor/runner.rb +362 -0
  94. data/lib/kward/prompt_interface/editor/runner_state.rb +78 -0
  95. data/lib/kward/prompt_interface/editor/scratchpad_languages.rb +74 -0
  96. data/lib/kward/prompt_interface/editor/scratchpad_runner.rb +182 -0
  97. data/lib/kward/prompt_interface/editor/state.rb +11 -11
  98. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +68 -6
  99. data/lib/kward/prompt_interface/editor/vibe_state.rb +3 -3
  100. data/lib/kward/prompt_interface/file_overlay.rb +71 -15
  101. data/lib/kward/prompt_interface/key_handler.rb +67 -0
  102. data/lib/kward/prompt_interface/layout.rb +1 -1
  103. data/lib/kward/prompt_interface/overlay_renderer.rb +7 -5
  104. data/lib/kward/prompt_interface/plugin_ui_requests.rb +82 -0
  105. data/lib/kward/prompt_interface/project_browser.rb +415 -14
  106. data/lib/kward/prompt_interface/runtime_state.rb +56 -2
  107. data/lib/kward/prompt_interface/screen.rb +11 -4
  108. data/lib/kward/prompt_interface/selection_prompt.rb +3 -1
  109. data/lib/kward/prompt_interface/slash_overlay.rb +19 -4
  110. data/lib/kward/prompt_interface/transcript_renderer.rb +12 -7
  111. data/lib/kward/prompt_interface.rb +151 -27
  112. data/lib/kward/prompts/commands.rb +3 -2
  113. data/lib/kward/prompts.rb +1 -1
  114. data/lib/kward/{adaptive_pty_output_sink.rb → pty/adaptive_output_sink.rb} +1 -1
  115. data/lib/kward/pty/detached_run.rb +44 -0
  116. data/lib/kward/{interactive_pty_runner.rb → pty/interactive_runner.rb} +104 -30
  117. data/lib/kward/{local_command_runner.rb → pty/local_command_runner.rb} +1 -1
  118. data/lib/kward/{local_pty_command_runner.rb → pty/local_pty_runner.rb} +3 -9
  119. data/lib/kward/{pty_output_sink.rb → pty/output_sink.rb} +52 -0
  120. data/lib/kward/{pty_transcript_normalizer.rb → pty/transcript_normalizer.rb} +1 -1
  121. data/lib/kward/rpc/plugin_chat_manager.rb +30 -10
  122. data/lib/kward/rpc/prompt_bridge.rb +25 -0
  123. data/lib/kward/rpc/server.rb +91 -12
  124. data/lib/kward/rpc/session_manager.rb +147 -44
  125. data/lib/kward/rpc/session_tree_rows.rb +2 -2
  126. data/lib/kward/rpc/tool_metadata.rb +1 -1
  127. data/lib/kward/rpc/transcript_normalizer.rb +7 -3
  128. data/lib/kward/sandbox/command_runner.rb +1 -1
  129. data/lib/kward/{session_catalog.rb → sessions/catalog.rb} +1 -1
  130. data/lib/kward/{session_store.rb → sessions/store.rb} +9 -9
  131. data/lib/kward/{session_tree_nodes.rb → sessions/tree_nodes.rb} +3 -3
  132. data/lib/kward/{session_tree_renderer.rb → sessions/tree_renderer.rb} +4 -4
  133. data/lib/kward/{session_tree_tool_display.rb → sessions/tree_tool_display.rb} +1 -1
  134. data/lib/kward/{ekwsh.rb → shell/kwsh.rb} +38 -19
  135. data/lib/kward/shell/kwshrc.rb +233 -0
  136. data/lib/kward/{persistent_shell_session.rb → shell/persistent_session.rb} +121 -28
  137. data/lib/kward/{shell_prompt.rb → shell/prompt.rb} +2 -0
  138. data/lib/kward/{shell_prompt_session.rb → shell/prompt_session.rb} +1 -1
  139. data/lib/kward/skills/trust_store.rb +1 -1
  140. data/lib/kward/tabs/driver.rb +194 -0
  141. data/lib/kward/{tab_store.rb → tabs/store.rb} +2 -2
  142. data/lib/kward/{ansi.rb → terminal/ansi.rb} +110 -10
  143. data/lib/kward/{clipboard.rb → terminal/clipboard.rb} +1 -1
  144. data/lib/kward/{terminal_image_support.rb → terminal/image_support.rb} +1 -1
  145. data/lib/kward/{terminal_keys.rb → terminal/keys.rb} +12 -0
  146. data/lib/kward/terminal/text.rb +121 -0
  147. data/lib/kward/text_matcher.rb +18 -0
  148. data/lib/kward/tools/base.rb +18 -0
  149. data/lib/kward/tools/context_for_task.rb +15 -6
  150. data/lib/kward/tools/edit_file.rb +9 -6
  151. data/lib/kward/tools/git_commit.rb +13 -7
  152. data/lib/kward/tools/list_directory.rb +4 -4
  153. data/lib/kward/tools/open_editor.rb +41 -0
  154. data/lib/kward/tools/plugin_tool.rb +41 -0
  155. data/lib/kward/tools/prepare_shell_command.rb +1 -1
  156. data/lib/kward/tools/read_file.rb +7 -6
  157. data/lib/kward/tools/registry.rb +109 -17
  158. data/lib/kward/tools/run_shell_command.rb +10 -8
  159. data/lib/kward/tools/search/code.rb +1 -1
  160. data/lib/kward/tools/summarize_file_structure.rb +5 -5
  161. data/lib/kward/tools/tool_call.rb +3 -1
  162. data/lib/kward/tools/typesafe_evaluate.rb +81 -0
  163. data/lib/kward/tools/workspace_targets.rb +58 -0
  164. data/lib/kward/tools/write_file.rb +9 -6
  165. data/lib/kward/{export_path.rb → transcripts/export_path.rb} +1 -1
  166. data/lib/kward/{markdown_transcript.rb → transcripts/markdown_transcript.rb} +2 -2
  167. data/lib/kward/transport/contracts.rb +200 -0
  168. data/lib/kward/transport/gateway.rb +79 -35
  169. data/lib/kward/transport/plugin_chat_gateway.rb +3 -2
  170. data/lib/kward/transport.rb +1 -200
  171. data/lib/kward/version.rb +1 -1
  172. data/lib/kward/{workspace_factory.rb → workspace/factory.rb} +2 -2
  173. data/lib/kward/{project_files.rb → workspace/files.rb} +2 -2
  174. data/lib/kward/{git_worktree_manager.rb → workspace/git_worktree_manager.rb} +28 -0
  175. data/lib/kward/{workspace.rb → workspace/workspace.rb} +3 -3
  176. data/templates/default/kward_navigation.rb +1 -0
  177. data/templates/default/layout/html/footer.erb +10 -0
  178. data/templates/default/layout/html/headers.erb +23 -0
  179. data/templates/default/layout/html/layout.erb +2 -2
  180. data/templates/default/layout/html/setup.rb +41 -2
  181. metadata +94 -47
  182. data/lib/kward/scratchpad_runner.rb +0 -56
  183. data/lib/kward/tab_driver.rb +0 -90
  184. /data/lib/kward/{editor_prompt.rb → cli/editor_prompt.rb} +0 -0
  185. /data/lib/kward/{editor_prompt_session.rb → cli/editor_prompt_session.rb} +0 -0
  186. /data/lib/kward/{diff_view_mode.rb → prompt_interface/editor/diff_view_mode.rb} +0 -0
  187. /data/lib/kward/{editor_mode.rb → prompt_interface/editor/editor_mode.rb} +0 -0
  188. /data/lib/kward/{session_diff.rb → sessions/diff.rb} +0 -0
  189. /data/lib/kward/{session_naming.rb → sessions/naming.rb} +0 -0
  190. /data/lib/kward/{session_trash.rb → sessions/trash.rb} +0 -0
  191. /data/lib/kward/{terminal_sequences.rb → terminal/sequences.rb} +0 -0
  192. /data/lib/kward/{transcript_export.rb → transcripts/transcript_export.rb} +0 -0
  193. /data/lib/kward/{path_guard.rb → workspace/path_guard.rb} +0 -0
@@ -0,0 +1,453 @@
1
+ require "json"
2
+ require "shellwords"
3
+ require_relative "../deep_copy"
4
+
5
+ # Namespace for the Kward CLI agent runtime.
6
+ module Kward
7
+ # Parses and validates arguments for typed plugin commands and actions.
8
+ # @api private
9
+ class PluginArguments
10
+ PROPERTY_NAME_PATTERN = /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/.freeze
11
+ SUPPORTED_TYPES = %w[string integer number boolean array object].freeze
12
+ BOOLEAN_VALUES = {
13
+ "true" => true,
14
+ "yes" => true,
15
+ "1" => true,
16
+ "false" => false,
17
+ "no" => false,
18
+ "0" => false
19
+ }.freeze
20
+
21
+ attr_reader :schema, :positionals
22
+
23
+ def initialize(label:, schema:, positionals: [])
24
+ @label = label
25
+ @schema = normalize_schema(schema)
26
+ @properties = @schema.fetch(:properties).to_h { |name, property| [name.to_s, property] }
27
+ @positionals = normalize_positionals(positionals)
28
+ validate_defaults
29
+ end
30
+
31
+ def parse(arguments)
32
+ values = case arguments
33
+ when String
34
+ parse_text(arguments)
35
+ when Hash
36
+ normalize_object(arguments)
37
+ when nil
38
+ {}
39
+ else
40
+ raise ArgumentError, "#{@label} arguments must be shell-style text or an object"
41
+ end
42
+ apply_defaults(values)
43
+ validate_required(values)
44
+ validate_object(values)
45
+ end
46
+
47
+ private
48
+
49
+ def normalize_schema(schema)
50
+ raise ArgumentError, "#{@label} schema must be an object" unless schema.is_a?(Hash)
51
+
52
+ normalized = symbolize_keys(DeepCopy.dup(schema))
53
+ type = normalized.fetch(:type, "object").to_s
54
+ raise ArgumentError, "#{@label} schema type must be object" unless type == "object"
55
+
56
+ properties = normalized.fetch(:properties, {})
57
+ required = Array(normalized.fetch(:required, [])).map(&:to_s)
58
+ raise ArgumentError, "#{@label} schema properties must be an object" unless properties.is_a?(Hash)
59
+ raise ArgumentError, "#{@label} schema required must be an array" unless normalized.fetch(:required, []).is_a?(Array)
60
+ raise ArgumentError, "#{@label} schema cannot allow additional properties" if normalized[:additionalProperties] == true
61
+
62
+ normalized_properties = properties.each_with_object({}) do |(name, property), result|
63
+ name = name.to_s
64
+ raise ArgumentError, "#{@label} property name is invalid: #{name}" unless name.match?(PROPERTY_NAME_PATTERN)
65
+ raise ArgumentError, "#{@label} property #{name} schema must be an object" unless property.is_a?(Hash)
66
+ raise ArgumentError, "#{@label} has duplicate property: #{name}" if result.key?(name.to_sym)
67
+
68
+ result[name.to_sym] = normalize_property_schema(name, property)
69
+ end
70
+ unknown_required = required - normalized_properties.keys.map(&:to_s)
71
+ unless unknown_required.empty?
72
+ raise ArgumentError, "#{@label} requires unknown properties: #{unknown_required.join(', ')}"
73
+ end
74
+
75
+ normalized[:type] = "object"
76
+ normalized[:properties] = normalized_properties
77
+ normalized[:required] = required
78
+ normalized[:additionalProperties] = false
79
+ DeepCopy.freeze(normalized)
80
+ end
81
+
82
+ def normalize_property_schema(name, property)
83
+ property = symbolize_keys(DeepCopy.dup(property))
84
+ type = property.fetch(:type, "string").to_s
85
+ raise ArgumentError, "#{@label} property #{name} has unsupported type: #{type}" unless SUPPORTED_TYPES.include?(type)
86
+
87
+ property[:type] = type
88
+ return normalize_schema(property) if type == "object"
89
+
90
+ if type == "array"
91
+ items = property.fetch(:items, { type: "string" })
92
+ raise ArgumentError, "#{@label} property #{name} items schema must be an object" unless items.is_a?(Hash)
93
+
94
+ items = symbolize_keys(DeepCopy.dup(items))
95
+ item_type = items.fetch(:type, "string").to_s
96
+ unless SUPPORTED_TYPES.include?(item_type) && !%w[array object].include?(item_type)
97
+ raise ArgumentError, "#{@label} property #{name} has unsupported array item type: #{item_type}"
98
+ end
99
+ items[:type] = item_type
100
+ property[:items] = items
101
+ end
102
+ if property.key?(:enum) && !property[:enum].is_a?(Array)
103
+ raise ArgumentError, "#{@label} property #{name} enum must be an array"
104
+ end
105
+ property
106
+ end
107
+
108
+ def normalize_positionals(positionals)
109
+ raise ArgumentError, "#{@label} positionals must be an array" unless positionals.is_a?(Array)
110
+
111
+ names = positionals.map(&:to_s)
112
+ unknown = names - @properties.keys
113
+ raise ArgumentError, "#{@label} has unknown positional properties: #{unknown.join(', ')}" unless unknown.empty?
114
+ raise ArgumentError, "#{@label} has duplicate positional properties" unless names.uniq.length == names.length
115
+
116
+ array_positions = names.each_index.select { |index| property_type(@properties.fetch(names[index])) == "array" }
117
+ if array_positions.any? { |index| index != names.length - 1 }
118
+ raise ArgumentError, "#{@label} array positional must be last"
119
+ end
120
+ names.freeze
121
+ end
122
+
123
+ def parse_text(text)
124
+ tokens = Shellwords.shellsplit(text.to_s)
125
+ values = {}
126
+ positional_values = []
127
+ options = true
128
+ index = 0
129
+ while index < tokens.length
130
+ token = tokens[index]
131
+ if options && token == "--"
132
+ options = false
133
+ elsif options && token.start_with?("--")
134
+ index = parse_option(tokens, index, values)
135
+ else
136
+ positional_values << token
137
+ end
138
+ index += 1
139
+ end
140
+ assign_positionals(values, positional_values)
141
+ values
142
+ rescue ArgumentError => error
143
+ raise error if error.message.start_with?(@label)
144
+
145
+ raise ArgumentError, "#{@label} arguments are invalid: #{error.message}"
146
+ end
147
+
148
+ def parse_option(tokens, index, values)
149
+ raw_option, inline_value = tokens[index].delete_prefix("--").split("=", 2)
150
+ name = property_name_for_option(raw_option)
151
+ negated = name.nil? && raw_option.start_with?("no-")
152
+ option = negated ? raw_option.delete_prefix("no-") : raw_option
153
+ name ||= property_name_for_option(option)
154
+ raise ArgumentError, "#{@label} has unknown option --#{raw_option}" unless name
155
+
156
+ property = @properties.fetch(name)
157
+ type = property_type(property)
158
+ if negated
159
+ raise ArgumentError, "#{@label} option --no-#{option} is only valid for booleans" unless type == "boolean"
160
+ raise ArgumentError, "#{@label} option --no-#{option} does not accept a value" if inline_value
161
+
162
+ assign_value(values, name, false, property)
163
+ return index
164
+ end
165
+
166
+ if type == "boolean" && inline_value.nil?
167
+ following = tokens[index + 1]
168
+ if following && BOOLEAN_VALUES.key?(following.downcase)
169
+ inline_value = following
170
+ index += 1
171
+ else
172
+ assign_value(values, name, true, property)
173
+ return index
174
+ end
175
+ end
176
+
177
+ if inline_value.nil?
178
+ index += 1
179
+ raise ArgumentError, "#{@label} option --#{option} requires a value" if index >= tokens.length
180
+
181
+ inline_value = tokens[index]
182
+ end
183
+ assign_value(values, name, coerce_text(inline_value, property, name), property)
184
+ index
185
+ end
186
+
187
+ def property_name_for_option(option)
188
+ @properties.keys.find { |name| name == option || name.tr("_", "-") == option }
189
+ end
190
+
191
+ def assign_positionals(values, tokens)
192
+ remaining = tokens.dup
193
+ @positionals.each do |name|
194
+ next if values.key?(name)
195
+ break if remaining.empty?
196
+
197
+ property = @properties.fetch(name)
198
+ if property_type(property) == "array"
199
+ remaining.each { |value| assign_value(values, name, coerce_text(value, property, name), property) }
200
+ remaining.clear
201
+ else
202
+ values[name] = coerce_text(remaining.shift, property, name)
203
+ end
204
+ end
205
+ return if remaining.empty?
206
+
207
+ raise ArgumentError, "#{@label} has unexpected positional argument: #{remaining.first}"
208
+ end
209
+
210
+ def assign_value(values, name, value, property)
211
+ if property_type(property) == "array"
212
+ values[name] ||= []
213
+ values[name] << value
214
+ elsif values.key?(name)
215
+ raise ArgumentError, "#{@label} option --#{name.tr('_', '-')} may only be provided once"
216
+ else
217
+ values[name] = value
218
+ end
219
+ end
220
+
221
+ def coerce_text(value, property, name)
222
+ schema = property_type(property) == "array" ? value_for(property, :items, {}) : property
223
+ type = property_type(schema)
224
+ coerced = case type
225
+ when "string" then value.to_s
226
+ when "integer" then convert_text(type, name) { Integer(value, 10) }
227
+ when "number" then convert_text(type, name) { Float(value) }
228
+ when "boolean"
229
+ BOOLEAN_VALUES.fetch(value.to_s.downcase) do
230
+ raise ArgumentError, "#{@label} argument #{name} must be a boolean"
231
+ end
232
+ when "object"
233
+ convert_text(type, name) do
234
+ parsed = JSON.parse(value)
235
+ raise JSON::ParserError unless parsed.is_a?(Hash)
236
+
237
+ parsed
238
+ end
239
+ else
240
+ value
241
+ end
242
+ validate_value(coerced, schema, name)
243
+ end
244
+
245
+ def convert_text(type, name)
246
+ yield
247
+ rescue ArgumentError, JSON::ParserError
248
+ raise ArgumentError, "#{@label} argument #{name} must be #{type}"
249
+ end
250
+
251
+ def normalize_object(arguments)
252
+ arguments.each_with_object({}) do |(key, value), result|
253
+ name = key.to_s
254
+ raise ArgumentError, "#{@label} has unknown argument: #{name}" unless @properties.key?(name)
255
+ raise ArgumentError, "#{@label} argument was provided more than once: #{name}" if result.key?(name)
256
+
257
+ result[name] = value
258
+ end
259
+ end
260
+
261
+ def validate_defaults
262
+ @properties.each do |name, property|
263
+ validate_value(value_for(property, :default), property, name) if key?(property, :default)
264
+ end
265
+ end
266
+
267
+ def apply_defaults(values)
268
+ @properties.each do |name, property|
269
+ next if values.key?(name) || !key?(property, :default)
270
+
271
+ values[name] = DeepCopy.dup(value_for(property, :default))
272
+ end
273
+ values
274
+ end
275
+
276
+ def validate_required(values)
277
+ missing = @schema.fetch(:required).reject { |name| values.key?(name) }
278
+ raise ArgumentError, "#{@label} is missing required arguments: #{missing.join(', ')}" unless missing.empty?
279
+ end
280
+
281
+ def validate_object(values)
282
+ values.each_with_object({}) do |(name, value), result|
283
+ result[name] = validate_value(value, @properties.fetch(name), name)
284
+ end
285
+ end
286
+
287
+ def validate_value(value, property, name)
288
+ type = property_type(property)
289
+ valid = case type
290
+ when "string" then value.is_a?(String)
291
+ when "integer" then value.is_a?(Integer)
292
+ when "number" then value.is_a?(Numeric) && (!value.respond_to?(:finite?) || value.finite?)
293
+ when "boolean" then value == true || value == false
294
+ when "array" then value.is_a?(Array)
295
+ when "object" then value.is_a?(Hash)
296
+ end
297
+ raise ArgumentError, "#{@label} argument #{name} must be #{type}" unless valid
298
+
299
+ value = value.map { |item| validate_value(item, value_for(property, :items, {}), name) } if type == "array"
300
+ value = normalize_nested_object(value, property, name) if type == "object"
301
+ enum = value_for(property, :enum)
302
+ if enum && !enum.include?(value)
303
+ raise ArgumentError, "#{@label} argument #{name} must be one of: #{enum.join(', ')}"
304
+ end
305
+ value
306
+ end
307
+
308
+ def normalize_nested_object(value, property, name)
309
+ nested_properties = value_for(property, :properties)
310
+ return value.each_with_object({}) { |(key, nested), result| result[key.to_s] = nested } unless nested_properties.is_a?(Hash)
311
+
312
+ contract = self.class.new(
313
+ label: "#{@label} argument #{name}",
314
+ schema: {
315
+ type: "object",
316
+ properties: nested_properties,
317
+ required: value_for(property, :required, []),
318
+ additionalProperties: false
319
+ }
320
+ )
321
+ contract.parse(value)
322
+ end
323
+
324
+ def property_type(property)
325
+ value_for(property, :type, "string").to_s
326
+ end
327
+
328
+ def symbolize_keys(hash)
329
+ hash.each_with_object({}) { |(key, value), result| result[key.to_sym] = value }
330
+ end
331
+
332
+ def key?(hash, key)
333
+ hash.key?(key) || hash.key?(key.to_s)
334
+ end
335
+
336
+ def value_for(hash, key, default = nil)
337
+ return hash[key] if hash.key?(key)
338
+ return hash[key.to_s] if hash.key?(key.to_s)
339
+
340
+ default
341
+ end
342
+ end
343
+
344
+ # Structured frontend and RPC result returned by typed commands and actions.
345
+ # @api public
346
+ class PluginResult
347
+ attr_reader :message, :data
348
+
349
+ def self.wrap(value)
350
+ return value if value.is_a?(self)
351
+ return new if value.nil?
352
+
353
+ new(message: value.to_s)
354
+ end
355
+
356
+ def initialize(message: nil, data: nil)
357
+ @message = message.nil? ? nil : message.to_s
358
+ @data = normalize_json(data)
359
+ freeze
360
+ end
361
+
362
+ def to_h
363
+ { message: message, data: data }.compact
364
+ end
365
+
366
+ private
367
+
368
+ def normalize_json(value)
369
+ case value
370
+ when nil, String, Integer, true, false
371
+ value
372
+ when Float
373
+ raise ArgumentError, "Plugin result data must contain finite numbers" unless value.finite?
374
+ value
375
+ when Array
376
+ value.map { |item| normalize_json(item) }.freeze
377
+ when Hash
378
+ value.each_with_object({}) do |(key, item), result|
379
+ unless key.is_a?(String) || key.is_a?(Symbol)
380
+ raise ArgumentError, "Plugin result data keys must be strings or symbols"
381
+ end
382
+ result[key.to_s] = normalize_json(item)
383
+ end.freeze
384
+ else
385
+ raise ArgumentError, "Plugin result data must contain only JSON-compatible values"
386
+ end
387
+ end
388
+ end
389
+
390
+ # Registered slash command with optional typed argument parsing.
391
+ # @api public
392
+ class PluginCommand
393
+ attr_reader :name, :description, :argument_hint, :schema, :positionals,
394
+ :plugin_id, :path, :handler
395
+
396
+ def initialize(name:, description: "", argument_hint: "", schema: nil, positionals: [], plugin_id: nil, path: nil, handler: nil)
397
+ @name = name
398
+ @description = description
399
+ @argument_hint = argument_hint
400
+ @plugin_id = plugin_id
401
+ @path = path
402
+ @handler = handler
403
+ @arguments = schema && PluginArguments.new(label: "Plugin command /#{name}", schema: schema, positionals: positionals)
404
+ @schema = @arguments&.schema
405
+ @positionals = @arguments&.positionals || [].freeze
406
+ freeze
407
+ end
408
+
409
+ def typed?
410
+ !@arguments.nil?
411
+ end
412
+
413
+ def parse_arguments(value)
414
+ typed? ? @arguments.parse(value) : value.to_s
415
+ end
416
+
417
+ def normalize_result(value)
418
+ typed? ? PluginResult.wrap(value) : value
419
+ end
420
+
421
+ def entry
422
+ { name: name, description: description, argument_hint: argument_hint }
423
+ end
424
+ end
425
+
426
+ # Registered namespaced action exposed to trusted RPC clients.
427
+ # @api public
428
+ class PluginAction
429
+ attr_reader :id, :name, :plugin_id, :description, :schema, :path, :handler
430
+
431
+ def initialize(name:, plugin_id:, description:, schema:, path:, handler:)
432
+ @name = name
433
+ @plugin_id = plugin_id
434
+ @id = "#{plugin_id}/#{name}"
435
+ @description = description
436
+ @path = path
437
+ @handler = handler
438
+ @arguments = PluginArguments.new(label: "Plugin action #{id}", schema: schema)
439
+ @schema = @arguments.schema
440
+ freeze
441
+ end
442
+
443
+ def parse_arguments(value)
444
+ raise ArgumentError, "Plugin action #{id} arguments must be an object" unless value.is_a?(Hash)
445
+
446
+ @arguments.parse(value)
447
+ end
448
+
449
+ def normalize_result(value)
450
+ PluginResult.wrap(value)
451
+ end
452
+ end
453
+ end
@@ -0,0 +1,121 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Versioned capabilities declared by a plugin-owned chat type.
4
+ class PluginChatCapabilities
5
+ API_VERSION = 1
6
+ ATTACHMENT_TYPES = %i[image].freeze
7
+ KEYS = %i[attachments steering transcript_paging].freeze
8
+
9
+ attr_reader :api_version, :attachments
10
+
11
+ def self.build(api:, capabilities:)
12
+ return legacy if api.nil? && capabilities.nil?
13
+ raise ArgumentError, "Plugin chat api and capabilities are required together" if api.nil? || capabilities.nil?
14
+
15
+ new(api_version: api, capabilities: capabilities, declared: true)
16
+ end
17
+
18
+ def self.legacy
19
+ @legacy ||= new(
20
+ api_version: nil,
21
+ capabilities: { attachments: ATTACHMENT_TYPES, steering: nil, transcript_paging: nil },
22
+ declared: false
23
+ )
24
+ end
25
+
26
+ def initialize(api_version:, capabilities:, declared:)
27
+ @declared = declared
28
+ @api_version = normalize_api_version(api_version)
29
+ values = normalize_capabilities(capabilities)
30
+ @attachments = normalize_attachments(values.fetch(:attachments, [])).freeze
31
+ @steering = normalize_boolean(values.fetch(:steering, false), :steering)
32
+ @transcript_paging = normalize_boolean(values.fetch(:transcript_paging, false), :transcript_paging)
33
+ freeze
34
+ end
35
+
36
+ def declared?
37
+ @declared
38
+ end
39
+
40
+ def steering?
41
+ @steering == true
42
+ end
43
+
44
+ def transcript_paging?
45
+ @transcript_paging == true
46
+ end
47
+
48
+ def allows_attachment?(type)
49
+ attachments.include?(type.to_sym)
50
+ end
51
+
52
+ def to_h
53
+ return {} unless declared?
54
+
55
+ {
56
+ api_version: api_version,
57
+ attachments: attachments.map(&:to_s).freeze,
58
+ steering: steering?,
59
+ transcript_paging: transcript_paging?
60
+ }.freeze
61
+ end
62
+
63
+ def validate_driver!(driver)
64
+ return driver unless declared?
65
+
66
+ missing = %i[messages submit descriptor supports_steering? assistant_label].reject { |method| driver.respond_to?(method) }
67
+ unless missing.empty?
68
+ raise ArgumentError, "Plugin chat driver is missing required methods: #{missing.join(', ')}"
69
+ end
70
+ if driver.supports_steering? != steering?
71
+ raise ArgumentError, "Plugin chat steering capability does not match driver.supports_steering?"
72
+ end
73
+ if transcript_paging? && !driver.respond_to?(:transcript_page)
74
+ raise ArgumentError, "Plugin chat declares transcript paging but driver does not implement transcript_page"
75
+ end
76
+
77
+ driver
78
+ end
79
+
80
+ private
81
+
82
+ def normalize_api_version(value)
83
+ return nil unless @declared
84
+
85
+ version = begin
86
+ Integer(value)
87
+ rescue ArgumentError, TypeError
88
+ nil
89
+ end
90
+ unless version == API_VERSION
91
+ raise ArgumentError, "Unsupported Kward plugin chat API #{value.inspect}; supported API: #{API_VERSION}"
92
+ end
93
+
94
+ version
95
+ end
96
+
97
+ def normalize_capabilities(capabilities)
98
+ raise ArgumentError, "Plugin chat capabilities must be an object" unless capabilities.is_a?(Hash)
99
+
100
+ values = capabilities.each_with_object({}) { |(key, value), result| result[key.to_sym] = value }
101
+ unknown = values.keys - KEYS
102
+ raise ArgumentError, "Unknown plugin chat capabilities: #{unknown.join(', ')}" unless unknown.empty?
103
+
104
+ values
105
+ end
106
+
107
+ def normalize_attachments(values)
108
+ attachments = Array(values).map(&:to_sym)
109
+ unknown = attachments - ATTACHMENT_TYPES
110
+ raise ArgumentError, "Unsupported plugin chat attachment types: #{unknown.join(', ')}" unless unknown.empty?
111
+
112
+ attachments.uniq
113
+ end
114
+
115
+ def normalize_boolean(value, name)
116
+ return value if value == true || value == false || value.nil?
117
+
118
+ raise ArgumentError, "Plugin chat #{name} capability must be true or false"
119
+ end
120
+ end
121
+ end