kward 0.84.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -0
- data/Gemfile.lock +2 -2
- data/README.md +2 -2
- data/doc/agent-tools.md +13 -1
- data/doc/api.md +17 -2
- data/doc/composer.md +1 -1
- data/doc/configuration.md +27 -4
- data/doc/extensibility.md +2 -1
- data/doc/git.md +3 -1
- data/doc/pan.md +6 -0
- data/doc/permissions.md +4 -4
- data/doc/plugins.md +464 -15
- data/doc/rpc.md +154 -16
- data/doc/sandboxing.md +11 -5
- data/doc/security.md +7 -1
- data/doc/session-management.md +5 -4
- data/doc/tabs.md +6 -2
- data/doc/transports.md +15 -0
- data/doc/usage.md +4 -1
- data/doc/workspace-tools.md +9 -0
- data/examples/plugins/space_invaders.rb +1 -1
- data/examples/plugins/stardate_footer.rb +2 -2
- data/examples/plugins/telegram/plugin.rb +1 -1
- data/lib/kward/agent.rb +24 -11
- data/lib/kward/cli/compaction.rb +9 -3
- data/lib/kward/cli/plugins.rb +81 -12
- data/lib/kward/cli/prompt_interface.rb +25 -5
- data/lib/kward/cli/rendering.rb +3 -0
- data/lib/kward/cli/runtime_helpers.rb +121 -27
- data/lib/kward/cli/sessions.rb +9 -5
- data/lib/kward/cli/settings/menus.rb +745 -0
- data/lib/kward/cli/settings/model.rb +327 -0
- data/lib/kward/cli/settings.rb +6 -1055
- data/lib/kward/cli/slash_commands.rb +45 -4
- data/lib/kward/cli/tabs.rb +167 -27
- data/lib/kward/{cli_transcript_formatter.rb → cli/transcript_formatter.rb} +3 -3
- data/lib/kward/cli/worktrees.rb +65 -2
- data/lib/kward/cli.rb +26 -24
- data/lib/kward/compactor.rb +18 -7
- data/lib/kward/config/core.rb +389 -0
- data/lib/kward/config/extensions.rb +96 -0
- data/lib/kward/config/prompts.rb +313 -0
- data/lib/kward/config/settings.rb +250 -0
- data/lib/kward/config_files.rb +14 -1008
- data/lib/kward/conversation.rb +31 -2
- data/lib/kward/image_attachments.rb +1 -1
- data/lib/kward/model/client.rb +2 -2
- data/lib/kward/model/copilot_models.rb +2 -2
- data/lib/kward/model/model_info.rb +20 -3
- data/lib/kward/{openrouter_model_cache.rb → model/openrouter_model_cache.rb} +3 -3
- data/lib/kward/model/payloads.rb +12 -3
- data/lib/kward/model/typesafe_client.rb +78 -0
- data/lib/kward/pan/server.rb +10 -7
- data/lib/kward/permissions/policy.rb +6 -2
- data/lib/kward/plugin_registry.rb +2 -659
- data/lib/kward/plugins/actions.rb +453 -0
- data/lib/kward/plugins/chat_contract.rb +121 -0
- data/lib/kward/{plugin_chat_runtime.rb → plugins/chat_runtime.rb} +62 -18
- data/lib/kward/plugins/host.rb +232 -0
- data/lib/kward/plugins/registry.rb +1190 -0
- data/lib/kward/plugins/resources.rb +206 -0
- data/lib/kward/plugins/turn_request.rb +36 -0
- data/lib/kward/plugins/ui.rb +219 -0
- data/lib/kward/prompt_interface/composer_renderer.rb +1 -1
- data/lib/kward/prompt_interface/composer_state.rb +1 -1
- data/lib/kward/prompt_interface/editor/controller.rb +1 -1
- data/lib/kward/prompt_interface/editor/runner.rb +1 -1
- data/lib/kward/prompt_interface/editor/state.rb +1 -1
- data/lib/kward/prompt_interface/layout.rb +1 -1
- data/lib/kward/prompt_interface/overlay_renderer.rb +1 -1
- data/lib/kward/prompt_interface/plugin_ui_requests.rb +82 -0
- data/lib/kward/prompt_interface/runtime_state.rb +6 -1
- data/lib/kward/prompt_interface/screen.rb +9 -2
- data/lib/kward/prompt_interface.rb +60 -11
- data/lib/kward/prompts/commands.rb +2 -1
- data/lib/kward/{adaptive_pty_output_sink.rb → pty/adaptive_output_sink.rb} +1 -1
- data/lib/kward/{interactive_pty_runner.rb → pty/interactive_runner.rb} +2 -2
- data/lib/kward/{local_command_runner.rb → pty/local_command_runner.rb} +1 -1
- data/lib/kward/{local_pty_command_runner.rb → pty/local_pty_runner.rb} +3 -9
- data/lib/kward/{pty_output_sink.rb → pty/output_sink.rb} +9 -4
- data/lib/kward/{pty_transcript_normalizer.rb → pty/transcript_normalizer.rb} +1 -1
- data/lib/kward/rpc/plugin_chat_manager.rb +30 -10
- data/lib/kward/rpc/prompt_bridge.rb +25 -0
- data/lib/kward/rpc/server.rb +91 -12
- data/lib/kward/rpc/session_manager.rb +147 -44
- data/lib/kward/rpc/session_tree_rows.rb +2 -2
- data/lib/kward/rpc/tool_metadata.rb +1 -1
- data/lib/kward/sandbox/command_runner.rb +1 -1
- data/lib/kward/{session_catalog.rb → sessions/catalog.rb} +1 -1
- data/lib/kward/{session_store.rb → sessions/store.rb} +9 -9
- data/lib/kward/{session_tree_nodes.rb → sessions/tree_nodes.rb} +3 -3
- data/lib/kward/{session_tree_renderer.rb → sessions/tree_renderer.rb} +4 -4
- data/lib/kward/{session_tree_tool_display.rb → sessions/tree_tool_display.rb} +1 -1
- data/lib/kward/{kwsh.rb → shell/kwsh.rb} +3 -3
- data/lib/kward/{persistent_shell_session.rb → shell/persistent_session.rb} +3 -3
- data/lib/kward/{shell_prompt_session.rb → shell/prompt_session.rb} +1 -1
- data/lib/kward/skills/trust_store.rb +1 -1
- data/lib/kward/tabs/driver.rb +194 -0
- data/lib/kward/{tab_store.rb → tabs/store.rb} +2 -2
- data/lib/kward/{clipboard.rb → terminal/clipboard.rb} +1 -1
- data/lib/kward/{terminal_image_support.rb → terminal/image_support.rb} +1 -1
- data/lib/kward/tools/base.rb +18 -0
- data/lib/kward/tools/context_for_task.rb +15 -6
- data/lib/kward/tools/edit_file.rb +9 -6
- data/lib/kward/tools/git_commit.rb +13 -7
- data/lib/kward/tools/list_directory.rb +4 -4
- data/lib/kward/tools/plugin_tool.rb +41 -0
- data/lib/kward/tools/prepare_shell_command.rb +1 -1
- data/lib/kward/tools/read_file.rb +7 -6
- data/lib/kward/tools/registry.rb +96 -13
- data/lib/kward/tools/run_shell_command.rb +10 -8
- data/lib/kward/tools/search/code.rb +1 -1
- data/lib/kward/tools/summarize_file_structure.rb +5 -5
- data/lib/kward/tools/tool_call.rb +2 -1
- data/lib/kward/tools/typesafe_evaluate.rb +81 -0
- data/lib/kward/tools/workspace_targets.rb +58 -0
- data/lib/kward/tools/write_file.rb +9 -6
- data/lib/kward/{export_path.rb → transcripts/export_path.rb} +1 -1
- data/lib/kward/{markdown_transcript.rb → transcripts/markdown_transcript.rb} +2 -2
- data/lib/kward/transport/contracts.rb +200 -0
- data/lib/kward/transport/gateway.rb +79 -35
- data/lib/kward/transport/plugin_chat_gateway.rb +3 -2
- data/lib/kward/transport.rb +1 -200
- data/lib/kward/version.rb +1 -1
- data/lib/kward/{workspace_factory.rb → workspace/factory.rb} +2 -2
- data/lib/kward/{git_worktree_manager.rb → workspace/git_worktree_manager.rb} +28 -0
- data/lib/kward/{workspace.rb → workspace/workspace.rb} +3 -3
- data/templates/default/fulldoc/html/css/kward.css +125 -0
- data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
- data/templates/default/fulldoc/html/setup.rb +1 -1
- data/templates/default/layout/html/layout.erb +16 -4
- metadata +67 -48
- data/lib/kward/tab_driver.rb +0 -90
- data/templates/default/fulldoc/html/images/kward_workflow.svg +0 -52
- /data/lib/kward/{editor_prompt.rb → cli/editor_prompt.rb} +0 -0
- /data/lib/kward/{editor_prompt_session.rb → cli/editor_prompt_session.rb} +0 -0
- /data/lib/kward/{diff_view_mode.rb → prompt_interface/editor/diff_view_mode.rb} +0 -0
- /data/lib/kward/{editor_mode.rb → prompt_interface/editor/editor_mode.rb} +0 -0
- /data/lib/kward/{markdown_code_block.rb → prompt_interface/editor/markdown_code_block.rb} +0 -0
- /data/lib/kward/{scratchpad_languages.rb → prompt_interface/editor/scratchpad_languages.rb} +0 -0
- /data/lib/kward/{scratchpad_runner.rb → prompt_interface/editor/scratchpad_runner.rb} +0 -0
- /data/lib/kward/{detached_run.rb → pty/detached_run.rb} +0 -0
- /data/lib/kward/{session_diff.rb → sessions/diff.rb} +0 -0
- /data/lib/kward/{session_naming.rb → sessions/naming.rb} +0 -0
- /data/lib/kward/{session_trash.rb → sessions/trash.rb} +0 -0
- /data/lib/kward/{kwshrc.rb → shell/kwshrc.rb} +0 -0
- /data/lib/kward/{shell_prompt.rb → shell/prompt.rb} +0 -0
- /data/lib/kward/{ansi.rb → terminal/ansi.rb} +0 -0
- /data/lib/kward/{terminal_keys.rb → terminal/keys.rb} +0 -0
- /data/lib/kward/{terminal_sequences.rb → terminal/sequences.rb} +0 -0
- /data/lib/kward/{terminal_text.rb → terminal/text.rb} +0 -0
- /data/lib/kward/{transcript_export.rb → transcripts/transcript_export.rb} +0 -0
- /data/lib/kward/{project_files.rb → workspace/files.rb} +0 -0
- /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
|