kward 0.77.0 → 0.78.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 +26 -0
- data/Gemfile.lock +2 -2
- data/README.md +1 -0
- data/doc/code-search.md +9 -6
- data/doc/configuration.md +39 -0
- data/doc/permissions.md +179 -0
- data/doc/plugins.md +58 -0
- data/doc/rpc.md +61 -0
- data/doc/security.md +4 -0
- data/doc/tabs.md +4 -3
- data/doc/web-search.md +4 -2
- data/lib/kward/auth/anthropic_oauth.rb +2 -2
- data/lib/kward/auth/github_oauth.rb +3 -3
- data/lib/kward/auth/oauth_helpers.rb +4 -2
- data/lib/kward/auth/openai_oauth.rb +2 -1
- data/lib/kward/cli/plugins.rb +22 -0
- data/lib/kward/cli/rendering.rb +7 -1
- data/lib/kward/cli/runtime_helpers.rb +14 -0
- data/lib/kward/cli/tabs.rb +140 -34
- data/lib/kward/cli.rb +29 -9
- data/lib/kward/config_files.rb +10 -0
- data/lib/kward/hooks/http_handler.rb +2 -1
- data/lib/kward/http.rb +18 -0
- data/lib/kward/model/client.rb +33 -11
- data/lib/kward/model/payloads.rb +6 -1
- data/lib/kward/openrouter_model_cache.rb +2 -1
- data/lib/kward/permissions/policy.rb +171 -0
- data/lib/kward/plugin_registry.rb +53 -1
- data/lib/kward/prompt_interface/approval_prompt.rb +62 -0
- data/lib/kward/prompt_interface/question_prompt.rb +12 -3
- data/lib/kward/prompt_interface.rb +2 -0
- data/lib/kward/rpc/plugin_chat_manager.rb +299 -0
- data/lib/kward/rpc/server.rb +35 -0
- data/lib/kward/rpc/session_manager.rb +1 -0
- data/lib/kward/rpc/transcript_normalizer.rb +14 -5
- data/lib/kward/starter_pack_installer.rb +3 -1
- data/lib/kward/tab_driver.rb +87 -0
- data/lib/kward/tab_store.rb +74 -12
- data/lib/kward/tools/code_search.rb +8 -2
- data/lib/kward/tools/fetch_content.rb +4 -2
- data/lib/kward/tools/fetch_raw.rb +3 -1
- data/lib/kward/tools/registry.rb +37 -4
- data/lib/kward/tools/search/code.rb +46 -12
- data/lib/kward/tools/search/web.rb +60 -17
- data/lib/kward/tools/search/web_fetch.rb +206 -38
- data/lib/kward/tools/web_search.rb +3 -1
- data/lib/kward/update_check.rb +2 -1
- data/lib/kward/version.rb +1 -1
- data/templates/default/kward_navigation.rb +2 -1
- data/templates/default/layout/html/layout.erb +2 -0
- data/templates/default/layout/html/setup.rb +1 -0
- metadata +7 -1
data/lib/kward/model/client.rb
CHANGED
|
@@ -6,6 +6,7 @@ require_relative "../auth/github_oauth"
|
|
|
6
6
|
require_relative "../auth/openai_oauth"
|
|
7
7
|
require_relative "../cancellation"
|
|
8
8
|
require_relative "../config_files"
|
|
9
|
+
require_relative "../http"
|
|
9
10
|
require_relative "../openrouter_model_cache"
|
|
10
11
|
require_relative "context_overflow"
|
|
11
12
|
require_relative "copilot_models"
|
|
@@ -35,6 +36,7 @@ module Kward
|
|
|
35
36
|
DEFAULT_OPENAI_MODEL = ModelInfo::DEFAULT_OPENAI_MODEL
|
|
36
37
|
DEFAULT_REASONING_EFFORT = ModelInfo::DEFAULT_REASONING_EFFORT
|
|
37
38
|
RETRY_DELAYS = [1, 2].freeze
|
|
39
|
+
DEFAULT_STREAM_IDLE_TIMEOUT_SECONDS = 120
|
|
38
40
|
NON_RETRYABLE_PROVIDER_LIMIT_PATTERNS = [
|
|
39
41
|
/GoUsageLimitError/i,
|
|
40
42
|
/FreeUsageLimitError/i,
|
|
@@ -335,7 +337,7 @@ module Kward
|
|
|
335
337
|
end
|
|
336
338
|
|
|
337
339
|
def chat_anthropic_provider(url:, token:, request_body:, current_model:, on_reasoning_delta:, on_assistant_delta:, cancellation:)
|
|
338
|
-
request = Net::HTTP::Post.new(url)
|
|
340
|
+
request = Http.apply_user_agent(Net::HTTP::Post.new(url))
|
|
339
341
|
request["Authorization"] = "Bearer #{token}"
|
|
340
342
|
request["Content-Type"] = "application/json"
|
|
341
343
|
request["Accept"] = "text/event-stream"
|
|
@@ -343,7 +345,7 @@ module Kward
|
|
|
343
345
|
request.body = request_body
|
|
344
346
|
|
|
345
347
|
message = nil
|
|
346
|
-
Net::HTTP.start(url.hostname, url.port, use_ssl: true, read_timeout:
|
|
348
|
+
Net::HTTP.start(url.hostname, url.port, use_ssl: true, read_timeout: stream_idle_timeout_seconds) do |http|
|
|
347
349
|
cancellation&.on_cancel { close_http(http) }
|
|
348
350
|
cancellation&.raise_if_cancelled!
|
|
349
351
|
http.request(request) do |response|
|
|
@@ -361,7 +363,7 @@ module Kward
|
|
|
361
363
|
end
|
|
362
364
|
|
|
363
365
|
def chat_openrouter_provider(url:, token:, request_body:, current_model:, on_assistant_delta:, cancellation:)
|
|
364
|
-
request = Net::HTTP::Post.new(url)
|
|
366
|
+
request = Http.apply_user_agent(Net::HTTP::Post.new(url))
|
|
365
367
|
request["Authorization"] = "Bearer #{token}"
|
|
366
368
|
request["Content-Type"] = "application/json"
|
|
367
369
|
request.body = request_body
|
|
@@ -547,7 +549,7 @@ module Kward
|
|
|
547
549
|
return [] if token.empty?
|
|
548
550
|
|
|
549
551
|
url = URI("#{@github_oauth.base_url}/models")
|
|
550
|
-
request = Net::HTTP::Get.new(url)
|
|
552
|
+
request = Http.apply_user_agent(Net::HTTP::Get.new(url))
|
|
551
553
|
request["Authorization"] = "Bearer #{token}"
|
|
552
554
|
request["Accept"] = "application/json"
|
|
553
555
|
copilot_headers([]).each { |key, value| request[key] = value }
|
|
@@ -577,7 +579,7 @@ module Kward
|
|
|
577
579
|
|
|
578
580
|
def copilot_responses_chat(token, request_body:, on_assistant_delta: nil, cancellation: nil)
|
|
579
581
|
url = URI("#{@github_oauth.base_url}/responses")
|
|
580
|
-
request = Net::HTTP::Post.new(url)
|
|
582
|
+
request = Http.apply_user_agent(Net::HTTP::Post.new(url))
|
|
581
583
|
request["Authorization"] = "Bearer #{token}"
|
|
582
584
|
request["Content-Type"] = "application/json"
|
|
583
585
|
request["Accept"] = "text/event-stream"
|
|
@@ -585,7 +587,7 @@ module Kward
|
|
|
585
587
|
request.body = request_body
|
|
586
588
|
|
|
587
589
|
message = nil
|
|
588
|
-
Net::HTTP.start(url.hostname, url.port, use_ssl: true, read_timeout:
|
|
590
|
+
Net::HTTP.start(url.hostname, url.port, use_ssl: true, read_timeout: stream_idle_timeout_seconds) do |http|
|
|
589
591
|
cancellation&.on_cancel { close_http(http) }
|
|
590
592
|
cancellation&.raise_if_cancelled!
|
|
591
593
|
http.request(request) do |response|
|
|
@@ -603,7 +605,7 @@ module Kward
|
|
|
603
605
|
end
|
|
604
606
|
|
|
605
607
|
def copilot_chat(url, token, messages, tools, request_body: nil, on_assistant_delta: nil, cancellation: nil)
|
|
606
|
-
request = Net::HTTP::Post.new(url)
|
|
608
|
+
request = Http.apply_user_agent(Net::HTTP::Post.new(url))
|
|
607
609
|
request["Authorization"] = "Bearer #{token}"
|
|
608
610
|
request["Content-Type"] = "application/json"
|
|
609
611
|
request["Accept"] = "text/event-stream"
|
|
@@ -633,7 +635,6 @@ module Kward
|
|
|
633
635
|
"anthropic-version" => "2023-06-01",
|
|
634
636
|
"anthropic-beta" => "claude-code-20250219,oauth-2025-04-20",
|
|
635
637
|
"anthropic-dangerous-direct-browser-access" => "true",
|
|
636
|
-
"user-agent" => "claude-cli/2.1.75",
|
|
637
638
|
"x-app" => "cli"
|
|
638
639
|
}
|
|
639
640
|
end
|
|
@@ -653,16 +654,16 @@ module Kward
|
|
|
653
654
|
end
|
|
654
655
|
|
|
655
656
|
def codex_chat(url, token, account_id, messages, tools, request_body: nil, on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, cancellation: nil, max_tokens: nil)
|
|
656
|
-
request = Net::HTTP::Post.new(url)
|
|
657
|
+
request = Http.apply_user_agent(Net::HTTP::Post.new(url))
|
|
657
658
|
request["Authorization"] = "Bearer #{token}"
|
|
658
659
|
request["ChatGPT-Account-Id"] = account_id if account_id
|
|
659
660
|
request["Content-Type"] = "application/json"
|
|
660
661
|
request["Accept"] = "text/event-stream"
|
|
661
|
-
request["originator"] = "codex_cli_rs"
|
|
662
662
|
request.body = request_body || JSON.dump(codex_payload(messages, tools, max_tokens: max_tokens))
|
|
663
|
+
apply_codex_identity(request, luna: luna_request?(request.body))
|
|
663
664
|
|
|
664
665
|
message = nil
|
|
665
|
-
Net::HTTP.start(url.hostname, url.port, use_ssl: true, read_timeout:
|
|
666
|
+
Net::HTTP.start(url.hostname, url.port, use_ssl: true, read_timeout: stream_idle_timeout_seconds) do |http|
|
|
666
667
|
cancellation&.on_cancel { close_http(http) }
|
|
667
668
|
cancellation&.raise_if_cancelled!
|
|
668
669
|
http.request(request) do |response|
|
|
@@ -684,6 +685,22 @@ module Kward
|
|
|
684
685
|
raise e
|
|
685
686
|
end
|
|
686
687
|
|
|
688
|
+
def apply_codex_identity(request, luna:)
|
|
689
|
+
request["originator"] = "kward"
|
|
690
|
+
return unless luna
|
|
691
|
+
|
|
692
|
+
# TODO: Remove this Luna-specific Responses Lite workaround when Codex accepts Kward's own client identity.
|
|
693
|
+
request["originator"] = "codex_cli_rs"
|
|
694
|
+
request["User-Agent"] = "codex_cli_rs/0.144.1"
|
|
695
|
+
request["x-openai-internal-codex-responses-lite"] = "true"
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
def luna_request?(request_body)
|
|
699
|
+
JSON.parse(request_body)["model"] == "gpt-5.6-luna"
|
|
700
|
+
rescue JSON::ParserError
|
|
701
|
+
false
|
|
702
|
+
end
|
|
703
|
+
|
|
687
704
|
def parse_codex_sse(body, on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil)
|
|
688
705
|
ModelStreamParser.parse_codex_sse(body, on_reasoning_delta: on_reasoning_delta, on_reasoning_boundary: on_reasoning_boundary, on_assistant_delta: on_assistant_delta, show_raw_reasoning: codex_show_raw_reasoning?, usage_normalizer: method(:normalized_usage), request_error_class: RequestError)
|
|
689
706
|
end
|
|
@@ -842,6 +859,11 @@ module Kward
|
|
|
842
859
|
@config["codex_show_raw_reasoning"] == true
|
|
843
860
|
end
|
|
844
861
|
|
|
862
|
+
def stream_idle_timeout_seconds
|
|
863
|
+
value = @config["stream_idle_timeout_seconds"].to_i
|
|
864
|
+
value.positive? ? value : DEFAULT_STREAM_IDLE_TIMEOUT_SECONDS
|
|
865
|
+
end
|
|
866
|
+
|
|
845
867
|
def load_config
|
|
846
868
|
ConfigFiles.read_config(@config_path)
|
|
847
869
|
end
|
data/lib/kward/model/payloads.rb
CHANGED
|
@@ -126,7 +126,12 @@ module Kward
|
|
|
126
126
|
store: false,
|
|
127
127
|
include: []
|
|
128
128
|
}
|
|
129
|
-
|
|
129
|
+
unless reasoning == false
|
|
130
|
+
effort = reasoning || reasoning_effort("Codex")
|
|
131
|
+
payload[:reasoning] = { effort: effort, summary: "auto" }
|
|
132
|
+
# TODO: Remove this Luna-specific Responses Lite workaround when Codex accepts Kward's own client identity.
|
|
133
|
+
payload[:reasoning][:context] = "all_turns" if parts[:model] == "gpt-5.6-luna"
|
|
134
|
+
end
|
|
130
135
|
payload
|
|
131
136
|
end
|
|
132
137
|
|
|
@@ -4,6 +4,7 @@ require "net/http"
|
|
|
4
4
|
require "time"
|
|
5
5
|
require "uri"
|
|
6
6
|
require_relative "config_files"
|
|
7
|
+
require_relative "http"
|
|
7
8
|
require_relative "private_file"
|
|
8
9
|
|
|
9
10
|
# Namespace for the Kward CLI agent runtime.
|
|
@@ -71,7 +72,7 @@ module Kward
|
|
|
71
72
|
private
|
|
72
73
|
|
|
73
74
|
def refresh_request
|
|
74
|
-
Net::HTTP::Get.new(MODELS_URL).tap do |request|
|
|
75
|
+
Http.apply_user_agent(Net::HTTP::Get.new(MODELS_URL)).tap do |request|
|
|
75
76
|
request["Authorization"] = "Bearer #{@api_key}"
|
|
76
77
|
request["Accept"] = "application/json"
|
|
77
78
|
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
# Namespace for Kward's permission-policy runtime.
|
|
4
|
+
module Kward
|
|
5
|
+
module Permissions
|
|
6
|
+
# Evaluates the opt-in permission policy for model-requested tool calls.
|
|
7
|
+
#
|
|
8
|
+
# The policy is intentionally a decision layer, not a process sandbox. It
|
|
9
|
+
# can prevent Kward from starting a tool, but it cannot constrain a shell
|
|
10
|
+
# command after it has begun. OS-enforced sandboxing belongs at a lower
|
|
11
|
+
# process boundary.
|
|
12
|
+
class Policy
|
|
13
|
+
MODES = %w[ask read-only workspace-write deny-by-default].freeze
|
|
14
|
+
NETWORK_TOOLS = %w[web_search fetch_content fetch_raw].freeze
|
|
15
|
+
FILE_CHANGE_TOOLS = %w[write_file edit_file].freeze
|
|
16
|
+
|
|
17
|
+
Decision = Struct.new(:action, :reason, keyword_init: true) do
|
|
18
|
+
def allowed?
|
|
19
|
+
action == :allow
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def approval_required?
|
|
23
|
+
action == :ask
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def denied?
|
|
27
|
+
action == :deny
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.from_config(config)
|
|
32
|
+
permissions = config["permissions"].is_a?(Hash) ? config["permissions"] : {}
|
|
33
|
+
new(
|
|
34
|
+
enabled: permissions["enabled"] == true,
|
|
35
|
+
mode: permissions["mode"],
|
|
36
|
+
allow: permissions["allow"],
|
|
37
|
+
ask: permissions["ask"],
|
|
38
|
+
deny: permissions["deny"],
|
|
39
|
+
write_scopes: permissions.key?("write_scopes") ? permissions["write_scopes"] : nil
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def initialize(enabled: false, mode: "ask", allow: [], ask: [], deny: [], write_scopes: nil)
|
|
44
|
+
@enabled = enabled == true
|
|
45
|
+
@mode = normalize_mode(mode)
|
|
46
|
+
@allow = normalize_rules(allow)
|
|
47
|
+
@ask = normalize_rules(ask)
|
|
48
|
+
@deny = normalize_rules(deny)
|
|
49
|
+
@write_scopes = normalize_scopes(write_scopes)
|
|
50
|
+
@session_allowed_tools = {}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def enabled?
|
|
54
|
+
@enabled
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Allows this model tool for the remaining lifetime of this policy object.
|
|
58
|
+
# A matching deny or ask rule still takes precedence over this temporary grant.
|
|
59
|
+
def allow_for_session!(tool_name)
|
|
60
|
+
@session_allowed_tools[tool_name.to_s] = true
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def decision_for(tool_name, arguments, source: nil)
|
|
64
|
+
return Decision.new(action: :allow, reason: "permissions disabled") unless enabled?
|
|
65
|
+
|
|
66
|
+
request = request_for(tool_name, arguments, source: source)
|
|
67
|
+
return Decision.new(action: :deny, reason: "matched deny rule") if matches?(@deny, request)
|
|
68
|
+
return Decision.new(action: :ask, reason: "matched ask rule") if matches?(@ask, request)
|
|
69
|
+
return Decision.new(action: :allow, reason: "matched allow rule") if matches?(@allow, request)
|
|
70
|
+
return Decision.new(action: :allow, reason: "allowed for this session") if @session_allowed_tools[request.fetch("tool")]
|
|
71
|
+
|
|
72
|
+
default_decision(request)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def normalize_mode(mode)
|
|
78
|
+
value = mode.to_s
|
|
79
|
+
MODES.include?(value) ? value : "ask"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def normalize_rules(rules)
|
|
83
|
+
Array(rules).filter_map do |rule|
|
|
84
|
+
next unless rule.is_a?(Hash)
|
|
85
|
+
|
|
86
|
+
rule.transform_keys(&:to_s).slice("tool", "path", "host", "command", "source")
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def normalize_scopes(scopes)
|
|
91
|
+
return nil if scopes.nil?
|
|
92
|
+
|
|
93
|
+
Array(scopes).map(&:to_s).reject(&:empty?)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def request_for(tool_name, arguments, source:)
|
|
97
|
+
name = tool_name.to_s
|
|
98
|
+
args = arguments.to_h
|
|
99
|
+
{
|
|
100
|
+
"tool" => name,
|
|
101
|
+
"path" => args["path"] || args[:path],
|
|
102
|
+
"host" => request_host(name, args),
|
|
103
|
+
"command" => args["command"] || args[:command],
|
|
104
|
+
"source" => source.to_s
|
|
105
|
+
}.compact.transform_values(&:to_s)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def request_host(name, arguments)
|
|
109
|
+
return nil unless %w[fetch_content fetch_raw].include?(name)
|
|
110
|
+
|
|
111
|
+
URI.parse((arguments["url"] || arguments[:url]).to_s).host
|
|
112
|
+
rescue URI::InvalidURIError
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def matches?(rules, request)
|
|
117
|
+
rules.any? { |rule| rule_matches?(rule, request) }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def rule_matches?(rule, request)
|
|
121
|
+
return false if rule.empty?
|
|
122
|
+
|
|
123
|
+
rule.all? do |field, pattern|
|
|
124
|
+
value = request[field]
|
|
125
|
+
value && glob_match?(pattern.to_s, value)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def glob_match?(pattern, value)
|
|
130
|
+
expression = Regexp.escape(pattern).gsub("\\*\\*", ".*").gsub("\\*", "[^/]*")
|
|
131
|
+
Regexp.new("\\A#{expression}\\z").match?(value)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def default_decision(request)
|
|
135
|
+
return Decision.new(action: :deny, reason: "read-only mode") if @mode == "read-only" && risky?(request)
|
|
136
|
+
return Decision.new(action: :deny, reason: "deny-by-default mode") if @mode == "deny-by-default" && risky?(request)
|
|
137
|
+
|
|
138
|
+
if file_change?(request) && outside_write_scopes?(request)
|
|
139
|
+
return Decision.new(action: :deny, reason: "path outside write scopes")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
if @mode == "workspace-write" && file_change?(request)
|
|
143
|
+
return Decision.new(action: :allow, reason: "workspace write mode")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
return Decision.new(action: :allow, reason: "read-only tool") unless risky?(request)
|
|
147
|
+
|
|
148
|
+
Decision.new(action: :ask, reason: "#{request.fetch("tool")} requires approval")
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def file_change?(request)
|
|
152
|
+
FILE_CHANGE_TOOLS.include?(request.fetch("tool"))
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def network?(request)
|
|
156
|
+
NETWORK_TOOLS.include?(request.fetch("tool")) || request["source"] == "mcp"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def risky?(request)
|
|
160
|
+
file_change?(request) || request.fetch("tool") == "run_shell_command" || network?(request)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def outside_write_scopes?(request)
|
|
164
|
+
return false if @write_scopes.nil?
|
|
165
|
+
|
|
166
|
+
path = request["path"]
|
|
167
|
+
path.nil? || !@write_scopes.any? { |scope| glob_match?(scope, path) }
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -34,6 +34,10 @@ module Kward
|
|
|
34
34
|
end
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
# Registered plugin-owned tab runtime. Its factory receives a
|
|
38
|
+
# `PluginTabHost` and its persisted descriptor, then returns a driver.
|
|
39
|
+
TabType = Struct.new(:id, :name, :title, :singleton, :rpc, :transcript_events, :path, :handler, keyword_init: true)
|
|
40
|
+
|
|
37
41
|
# Read-only event passed to plugin transcript observers.
|
|
38
42
|
TranscriptEvent = Struct.new(:type, :payload, keyword_init: true) do
|
|
39
43
|
def to_h
|
|
@@ -264,6 +268,23 @@ module Kward
|
|
|
264
268
|
def interactive_command(name, rows:, fps: 30, description: "", argument_hint: "", &block)
|
|
265
269
|
@registry.register_interactive_command(name, rows: rows, fps: fps, description: description, argument_hint: argument_hint, path: @path, &block)
|
|
266
270
|
end
|
|
271
|
+
|
|
272
|
+
# Registers a plugin-owned tab type for the interactive CLI. `id` is a
|
|
273
|
+
# durable identifier used in persisted tab layouts and must not change.
|
|
274
|
+
# The factory receives a `PluginTabHost` and a descriptor hash.
|
|
275
|
+
#
|
|
276
|
+
# @param name [String] command name used by `/tab open <name>`
|
|
277
|
+
# @param id [String] stable persisted tab type identifier
|
|
278
|
+
# @param title [String] default tab label
|
|
279
|
+
# @param singleton [Symbol] `:global` for one shared plugin runtime
|
|
280
|
+
# @param transcript_events [Boolean] allow global transcript observers to receive this tab's events
|
|
281
|
+
# @yieldparam host [PluginTabHost] supported host dependencies
|
|
282
|
+
# @yieldparam descriptor [Hash] persisted tab descriptor
|
|
283
|
+
# @return [void]
|
|
284
|
+
# @api public
|
|
285
|
+
def tab_type(name, id:, title: nil, singleton: nil, rpc: false, transcript_events: false, &block)
|
|
286
|
+
@registry.register_tab_type(name, id: id, title: title, singleton: singleton, rpc: rpc, transcript_events: transcript_events, path: @path, &block)
|
|
287
|
+
end
|
|
267
288
|
end
|
|
268
289
|
|
|
269
290
|
# Mutable singleton guard used while loading trusted plugin files.
|
|
@@ -282,6 +303,8 @@ module Kward
|
|
|
282
303
|
@reserved_commands = reserved_commands.map(&:to_s)
|
|
283
304
|
@commands = {}
|
|
284
305
|
@interactive_commands = {}
|
|
306
|
+
@tab_types = {}
|
|
307
|
+
@tab_types_by_id = {}
|
|
285
308
|
@footer = nil
|
|
286
309
|
@footer_path = nil
|
|
287
310
|
@transcript_event_handlers = []
|
|
@@ -312,6 +335,18 @@ module Kward
|
|
|
312
335
|
@interactive_commands[name.to_s]
|
|
313
336
|
end
|
|
314
337
|
|
|
338
|
+
def tab_types
|
|
339
|
+
@tab_types.values
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def tab_type_for(name)
|
|
343
|
+
@tab_types[name.to_s]
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def tab_type_for_id(id)
|
|
347
|
+
@tab_types_by_id[id.to_s]
|
|
348
|
+
end
|
|
349
|
+
|
|
315
350
|
def footer_renderer
|
|
316
351
|
@footer
|
|
317
352
|
end
|
|
@@ -429,6 +464,23 @@ module Kward
|
|
|
429
464
|
)
|
|
430
465
|
end
|
|
431
466
|
|
|
467
|
+
def register_tab_type(name, id:, title: nil, singleton: nil, rpc: false, transcript_events: false, path: nil, &handler)
|
|
468
|
+
name = name.to_s
|
|
469
|
+
id = id.to_s
|
|
470
|
+
raise "Plugin tab type name is invalid: #{name}" unless name.match?(COMMAND_NAME_PATTERN)
|
|
471
|
+
raise "Plugin tab type id is required" if id.empty?
|
|
472
|
+
raise "Plugin tab type #{name} requires a handler" unless handler
|
|
473
|
+
|
|
474
|
+
if @tab_types.key?(name) || @tab_types_by_id.key?(id)
|
|
475
|
+
warn "Warning: skipping duplicate Kward plugin tab type #{id}: #{path}"
|
|
476
|
+
return nil
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
tab_type = TabType.new(id: id, name: name, title: title.to_s.empty? ? name.capitalize : title.to_s, singleton: singleton&.to_sym, rpc: rpc == true, transcript_events: transcript_events == true, path: path, handler: handler)
|
|
480
|
+
@tab_types[name] = tab_type
|
|
481
|
+
@tab_types_by_id[id] = tab_type
|
|
482
|
+
end
|
|
483
|
+
|
|
432
484
|
def register_footer(path: nil, &renderer)
|
|
433
485
|
raise "Plugin footer requires a renderer" unless renderer
|
|
434
486
|
|
|
@@ -500,7 +552,7 @@ module Kward
|
|
|
500
552
|
end
|
|
501
553
|
end
|
|
502
554
|
|
|
503
|
-
def transcript_event(type, payload)
|
|
555
|
+
def transcript_event(type, payload = {})
|
|
504
556
|
TranscriptEvent.new(
|
|
505
557
|
type: type,
|
|
506
558
|
payload: DeepCopy.freeze(DeepCopy.dup(payload))
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
# Namespace for the Kward CLI agent runtime.
|
|
4
|
+
module Kward
|
|
5
|
+
class PromptInterface
|
|
6
|
+
# Tool-approval overlay built on the structured question modal.
|
|
7
|
+
module ApprovalPrompt
|
|
8
|
+
# Asks the captain to approve a model-requested tool call. Cancellation is
|
|
9
|
+
# intentionally a denial so a closed or interrupted overlay never permits
|
|
10
|
+
# a side effect.
|
|
11
|
+
def ask_tool_approval(tool_name:, args:, reason: nil)
|
|
12
|
+
summary, details = tool_approval_details(tool_name, args)
|
|
13
|
+
question = (["The agent wants to #{summary}."] + Array(details) + [reason.to_s].reject(&:empty?)).join("\n")
|
|
14
|
+
answers = ask_user_question([
|
|
15
|
+
{
|
|
16
|
+
header: "Approval required · #{tool_approval_title(tool_name)}",
|
|
17
|
+
question: question,
|
|
18
|
+
options: [
|
|
19
|
+
{ label: "Allow once", description: "Run this tool call." },
|
|
20
|
+
{ label: "Allow this tool for this session", description: "Run this call and future calls to #{tool_name}." },
|
|
21
|
+
{ label: "Deny", description: "Do not run this tool call." }
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
])
|
|
25
|
+
answer = answers&.first
|
|
26
|
+
return { denied_message: answer[:answer] } if answer&.fetch(:custom, false) && !answer[:answer].to_s.empty?
|
|
27
|
+
|
|
28
|
+
case answer&.fetch(:answer, nil)
|
|
29
|
+
when "Allow once" then true
|
|
30
|
+
when "Allow this tool for this session" then :allow_for_session
|
|
31
|
+
else false
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def tool_approval_title(tool_name)
|
|
38
|
+
case tool_name.to_s
|
|
39
|
+
when "run_shell_command" then "Shell command"
|
|
40
|
+
when "write_file" then "Write file"
|
|
41
|
+
when "edit_file" then "Edit file"
|
|
42
|
+
when "fetch_content", "fetch_raw" then "Network request"
|
|
43
|
+
when "web_search" then "Web search"
|
|
44
|
+
else tool_name.to_s.tr("_", " ").capitalize
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def tool_approval_details(tool_name, args)
|
|
49
|
+
action = case tool_name.to_s
|
|
50
|
+
when "run_shell_command" then "run this shell command"
|
|
51
|
+
when "write_file" then "write this file"
|
|
52
|
+
when "edit_file" then "edit this file"
|
|
53
|
+
when "read_file", "read_skill" then "read these resources"
|
|
54
|
+
when "fetch_content", "fetch_raw" then "make this network request"
|
|
55
|
+
when "web_search" then "search the web"
|
|
56
|
+
else "use #{tool_name}"
|
|
57
|
+
end
|
|
58
|
+
[action, ["Arguments:\n#{JSON.pretty_generate(args.to_h)}"]]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -337,11 +337,12 @@ module Kward
|
|
|
337
337
|
|
|
338
338
|
def question_overlay_rows(width)
|
|
339
339
|
title = "Question #{@question_state[:index]}/#{@question_state[:total]} · #{@question_state[:header]}"
|
|
340
|
-
|
|
341
|
-
|
|
340
|
+
content_width = [overlay_card_width(width) - 4, 1].max
|
|
341
|
+
lines = question_text_rows(@question_state[:question], content_width, :bold)
|
|
342
|
+
lines.concat([
|
|
342
343
|
overlay_text_line("↑/↓ select · Enter choose · Esc cancel", :muted),
|
|
343
344
|
overlay_blank_line
|
|
344
|
-
]
|
|
345
|
+
])
|
|
345
346
|
question_choices.each_with_index do |choice, index|
|
|
346
347
|
selected = index == question_selection_index
|
|
347
348
|
lines << overlay_choice_line(choice_text(choice, selected: selected), selected: selected)
|
|
@@ -349,6 +350,14 @@ module Kward
|
|
|
349
350
|
overlay_card_rows(title, lines, width)
|
|
350
351
|
end
|
|
351
352
|
|
|
353
|
+
def question_text_rows(text, width, style)
|
|
354
|
+
text.to_s.split("\n", -1).flat_map do |line|
|
|
355
|
+
wrapped = ANSI.wrap_visible(line, width)
|
|
356
|
+
wrapped = [""] if wrapped.empty?
|
|
357
|
+
wrapped.map { |row| overlay_text_line(row, style) }
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
352
361
|
def choice_text(choice, selected: false)
|
|
353
362
|
if choice[:custom]
|
|
354
363
|
selected ? "Type a custom answer below." : "Type something."
|
|
@@ -25,6 +25,7 @@ require_relative "prompt_interface/file_overlay"
|
|
|
25
25
|
require_relative "prompt_interface/project_browser"
|
|
26
26
|
require_relative "prompt_interface/selection_prompt"
|
|
27
27
|
require_relative "prompt_interface/question_prompt"
|
|
28
|
+
require_relative "prompt_interface/approval_prompt"
|
|
28
29
|
require_relative "prompt_interface/git_prompt"
|
|
29
30
|
require_relative "prompt_interface/overlay_renderer"
|
|
30
31
|
require_relative "prompt_interface/editor/renderer"
|
|
@@ -75,6 +76,7 @@ module Kward
|
|
|
75
76
|
include ProjectBrowser
|
|
76
77
|
include SelectionPrompt
|
|
77
78
|
include QuestionPrompt
|
|
79
|
+
include ApprovalPrompt
|
|
78
80
|
include GitPrompt
|
|
79
81
|
include OverlayRenderer
|
|
80
82
|
include EditorRenderer
|