kward 0.67.0 → 0.68.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 +5 -5
- data/doc/authentication.md +24 -1
- data/doc/configuration.md +9 -2
- data/doc/extensibility.md +1 -1
- data/doc/getting-started.md +4 -6
- data/doc/plugins.md +0 -2
- data/doc/releasing.md +7 -8
- data/doc/rpc.md +6 -6
- data/doc/usage.md +5 -2
- data/doc/web-search.md +2 -2
- data/kward.gemspec +4 -0
- data/lib/kward/agent.rb +29 -2
- data/lib/kward/ansi.rb +3 -0
- data/lib/kward/auth/anthropic_oauth.rb +291 -0
- data/lib/kward/auth/file.rb +2 -0
- data/lib/kward/auth/github_oauth.rb +3 -0
- data/lib/kward/auth/openai_oauth.rb +4 -0
- data/lib/kward/auth/openrouter_api_key.rb +2 -0
- data/lib/kward/cancellation.rb +3 -0
- data/lib/kward/cli/auth_commands.rb +82 -0
- data/lib/kward/cli/commands.rb +222 -0
- data/lib/kward/cli/compaction.rb +25 -0
- data/lib/kward/cli/doctor.rb +121 -0
- data/lib/kward/cli/interactive_turn.rb +225 -0
- data/lib/kward/cli/memory_commands.rb +133 -0
- data/lib/kward/cli/plugins.rb +112 -0
- data/lib/kward/cli/prompt_interface.rb +132 -0
- data/lib/kward/cli/rendering.rb +389 -0
- data/lib/kward/cli/runtime_helpers.rb +159 -0
- data/lib/kward/cli/sessions.rb +376 -0
- data/lib/kward/cli/settings.rb +663 -0
- data/lib/kward/cli/slash_commands.rb +112 -0
- data/lib/kward/cli/stats.rb +64 -0
- data/lib/kward/cli/tool_summaries.rb +153 -0
- data/lib/kward/cli.rb +38 -2790
- data/lib/kward/cli_transcript_formatter.rb +4 -7
- data/lib/kward/clipboard.rb +1 -0
- data/lib/kward/compaction/file_operation_tracker.rb +3 -0
- data/lib/kward/compactor.rb +29 -7
- data/lib/kward/config_files.rb +33 -24
- data/lib/kward/conversation.rb +70 -5
- data/lib/kward/events.rb +2 -0
- data/lib/kward/export_path.rb +2 -0
- data/lib/kward/image_attachments.rb +2 -0
- data/lib/kward/markdown_transcript.rb +2 -0
- data/lib/kward/memory/manager.rb +13 -0
- data/lib/kward/message_access.rb +23 -2
- data/lib/kward/message_text.rb +45 -0
- data/lib/kward/model/chat_invocation.rb +2 -0
- data/lib/kward/model/client.rb +295 -77
- data/lib/kward/model/context_overflow.rb +2 -0
- data/lib/kward/model/context_usage.rb +3 -0
- data/lib/kward/model/model_info.rb +143 -4
- data/lib/kward/model/payloads.rb +166 -13
- data/lib/kward/model/retry_message.rb +2 -0
- data/lib/kward/model/stream_parser.rb +129 -0
- data/lib/kward/pan/server.rb +3 -1
- data/lib/kward/plugin_registry.rb +12 -0
- data/lib/kward/private_file.rb +2 -0
- data/lib/kward/prompt_interface/banner.rb +3 -0
- data/lib/kward/prompt_interface/composer_controller.rb +262 -0
- data/lib/kward/prompt_interface/composer_renderer.rb +172 -0
- data/lib/kward/prompt_interface/composer_state.rb +221 -0
- data/lib/kward/prompt_interface/key_handler.rb +365 -0
- data/lib/kward/prompt_interface/layout.rb +31 -0
- data/lib/kward/prompt_interface/overlay_renderer.rb +111 -0
- data/lib/kward/prompt_interface/prompt_renderer.rb +91 -0
- data/lib/kward/prompt_interface/question_prompt.rb +328 -0
- data/lib/kward/prompt_interface/runtime_state.rb +59 -0
- data/lib/kward/prompt_interface/screen.rb +186 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +242 -0
- data/lib/kward/prompt_interface/slash_overlay.rb +102 -0
- data/lib/kward/prompt_interface/stream_state.rb +65 -0
- data/lib/kward/prompt_interface/transcript_buffer.rb +85 -0
- data/lib/kward/prompt_interface/transcript_renderer.rb +142 -0
- data/lib/kward/prompt_interface.rb +69 -1832
- data/lib/kward/prompts/commands.rb +2 -0
- data/lib/kward/prompts/templates.rb +3 -0
- data/lib/kward/prompts.rb +2 -0
- data/lib/kward/question_contract.rb +66 -0
- data/lib/kward/resources/avatar_kward_logo.rb +2 -0
- data/lib/kward/resources/pixel_logo.rb +2 -0
- data/lib/kward/rpc/attachment_normalizer.rb +60 -0
- data/lib/kward/rpc/auth_manager.rb +65 -11
- data/lib/kward/rpc/config_manager.rb +11 -0
- data/lib/kward/rpc/prompt_bridge.rb +5 -26
- data/lib/kward/rpc/redactor.rb +3 -0
- data/lib/kward/rpc/runtime_payloads.rb +4 -1
- data/lib/kward/rpc/server.rb +37 -10
- data/lib/kward/rpc/session_manager.rb +123 -347
- data/lib/kward/rpc/session_metrics.rb +68 -0
- data/lib/kward/rpc/session_tree.rb +48 -0
- data/lib/kward/rpc/session_tree_rows.rb +208 -0
- data/lib/kward/rpc/tool_event_normalizer.rb +3 -0
- data/lib/kward/rpc/tool_metadata.rb +3 -0
- data/lib/kward/rpc/transcript_normalizer.rb +3 -0
- data/lib/kward/rpc/transport.rb +3 -0
- data/lib/kward/session_diff.rb +2 -0
- data/lib/kward/session_store.rb +125 -31
- data/lib/kward/session_trash.rb +1 -0
- data/lib/kward/session_tree_renderer.rb +8 -41
- data/lib/kward/session_tree_tool_display.rb +56 -0
- data/lib/kward/skills/registry.rb +3 -0
- data/lib/kward/starter_pack_installer.rb +1 -0
- data/lib/kward/steering.rb +2 -0
- data/lib/kward/telemetry/logger.rb +3 -0
- data/lib/kward/telemetry/stats.rb +3 -0
- data/lib/kward/tools/ask_user_question.rb +20 -32
- data/lib/kward/tools/base.rb +8 -0
- data/lib/kward/tools/code_search.rb +5 -0
- data/lib/kward/tools/edit_file.rb +5 -0
- data/lib/kward/tools/list_directory.rb +5 -0
- data/lib/kward/tools/read_file.rb +5 -0
- data/lib/kward/tools/read_skill.rb +5 -0
- data/lib/kward/tools/registry.rb +33 -2
- data/lib/kward/tools/run_shell_command.rb +5 -0
- data/lib/kward/tools/search/code.rb +7 -0
- data/lib/kward/tools/search/web.rb +17 -14
- data/lib/kward/tools/tool_call.rb +25 -5
- data/lib/kward/tools/web_search.rb +7 -1
- data/lib/kward/tools/write_file.rb +5 -0
- data/lib/kward/transcript_export.rb +2 -0
- data/lib/kward/version.rb +2 -1
- data/lib/kward/workspace.rb +45 -5
- metadata +43 -1
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "digest"
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "socket"
|
|
7
|
+
require "time"
|
|
8
|
+
require "uri"
|
|
9
|
+
require_relative "file"
|
|
10
|
+
require_relative "../config_files"
|
|
11
|
+
|
|
12
|
+
# Namespace for the Kward CLI agent runtime.
|
|
13
|
+
module Kward
|
|
14
|
+
# OAuth helper for Anthropic Claude Pro/Max subscription credentials.
|
|
15
|
+
class AnthropicOAuth
|
|
16
|
+
AUTHORIZE_URL = "https://claude.ai/oauth/authorize"
|
|
17
|
+
TOKEN_URL = URI("https://platform.claude.com/v1/oauth/token")
|
|
18
|
+
DEFAULT_PORT = 53_692
|
|
19
|
+
CALLBACK_PATH = "/callback"
|
|
20
|
+
DEFAULT_CLIENT_ID = Base64.decode64("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl")
|
|
21
|
+
SCOPE = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload"
|
|
22
|
+
|
|
23
|
+
attr_reader :auth_path
|
|
24
|
+
|
|
25
|
+
# Creates an object for Anthropic OAuth credentials.
|
|
26
|
+
def initialize(auth_path: AnthropicOAuth.default_auth_path, client_id: nil, config_path: ConfigFiles.config_path)
|
|
27
|
+
@auth_path = File.expand_path(auth_path)
|
|
28
|
+
@client_id = client_id
|
|
29
|
+
@config_path = File.expand_path(config_path)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.default_auth_path
|
|
33
|
+
File.expand_path(ENV["KWARD_ANTHROPIC_AUTH_PATH"] || "~/.kward/anthropic_auth.json")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def access_token
|
|
37
|
+
auth = current_auth
|
|
38
|
+
auth&.fetch("tokens", {})&.fetch("access_token", nil)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def logged_in?
|
|
42
|
+
!access_token.to_s.empty?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def login(prompt:, open_browser: true, timeout_seconds: 120)
|
|
46
|
+
flow = start_login_flow
|
|
47
|
+
pkce = flow[:pkce]
|
|
48
|
+
state = flow[:state]
|
|
49
|
+
server = flow[:server]
|
|
50
|
+
redirect_uri = flow[:redirect_uri]
|
|
51
|
+
url = flow[:authorization_url]
|
|
52
|
+
|
|
53
|
+
prompt.say("Anthropic login URL:\n#{url}\n")
|
|
54
|
+
prompt.say("Waiting for browser login. If it does not complete, paste the callback URL when prompted.")
|
|
55
|
+
browser_opened = open_browser && open_url(url)
|
|
56
|
+
|
|
57
|
+
code = wait_for_callback(server, expected_state: state, timeout_seconds: browser_opened ? timeout_seconds : 5)
|
|
58
|
+
unless code
|
|
59
|
+
input = prompt.ask("Paste callback URL or authorization code:")
|
|
60
|
+
code = authorization_code_from(input.to_s, expected_state: state)
|
|
61
|
+
end
|
|
62
|
+
raise "Missing authorization code" if code.to_s.empty?
|
|
63
|
+
|
|
64
|
+
complete_login_flow(code: code, redirect_uri: redirect_uri, code_verifier: pkce[:verifier])
|
|
65
|
+
auth_path
|
|
66
|
+
ensure
|
|
67
|
+
server&.close unless server&.closed?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def authorization_url(redirect_uri:, code_challenge:, state:)
|
|
71
|
+
query = URI.encode_www_form(
|
|
72
|
+
code: "true",
|
|
73
|
+
client_id: client_id,
|
|
74
|
+
response_type: "code",
|
|
75
|
+
redirect_uri: redirect_uri,
|
|
76
|
+
scope: SCOPE,
|
|
77
|
+
code_challenge: code_challenge,
|
|
78
|
+
code_challenge_method: "S256",
|
|
79
|
+
state: state
|
|
80
|
+
)
|
|
81
|
+
"#{AUTHORIZE_URL}?#{query}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def start_login_flow
|
|
85
|
+
pkce = generate_pkce
|
|
86
|
+
state = pkce[:verifier]
|
|
87
|
+
server = start_callback_server
|
|
88
|
+
redirect_uri = "http://localhost:#{server.addr[1]}#{CALLBACK_PATH}"
|
|
89
|
+
{
|
|
90
|
+
pkce: pkce,
|
|
91
|
+
state: state,
|
|
92
|
+
server: server,
|
|
93
|
+
redirect_uri: redirect_uri,
|
|
94
|
+
authorization_url: authorization_url(redirect_uri: redirect_uri, code_challenge: pkce[:challenge], state: state)
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def wait_for_login_callback(server, expected_state:, timeout_seconds:)
|
|
99
|
+
wait_for_callback(server, expected_state: expected_state, timeout_seconds: timeout_seconds)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def complete_login_flow(code:, redirect_uri:, code_verifier:)
|
|
103
|
+
tokens = exchange_code_for_tokens(code: code, redirect_uri: redirect_uri, code_verifier: code_verifier)
|
|
104
|
+
save_auth(tokens: tokens)
|
|
105
|
+
tokens
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def authorization_code_from(input, expected_state: nil)
|
|
109
|
+
value = input.strip
|
|
110
|
+
return "" if value.empty?
|
|
111
|
+
|
|
112
|
+
uri = URI.parse(value)
|
|
113
|
+
params = URI.decode_www_form(uri.query.to_s).to_h
|
|
114
|
+
if params.key?("code")
|
|
115
|
+
raise "OAuth state mismatch" if expected_state && params["state"].to_s != expected_state.to_s
|
|
116
|
+
|
|
117
|
+
return params["code"]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
if value.include?("#")
|
|
121
|
+
code, state = value.split("#", 2)
|
|
122
|
+
raise "OAuth state mismatch" if expected_state && !state.to_s.empty? && state != expected_state
|
|
123
|
+
|
|
124
|
+
return code
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
value
|
|
128
|
+
rescue URI::InvalidURIError
|
|
129
|
+
value
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def save_auth(tokens: {})
|
|
133
|
+
data = {
|
|
134
|
+
"auth_mode" => "anthropic_oauth",
|
|
135
|
+
"tokens" => tokens,
|
|
136
|
+
"saved_at" => Time.now.utc.iso8601,
|
|
137
|
+
"expires_at" => expires_at_for(tokens)
|
|
138
|
+
}.compact
|
|
139
|
+
|
|
140
|
+
AuthFile.write_json(@auth_path, data)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Performs refresh for Anthropic OAuth credentials.
|
|
144
|
+
def refresh!
|
|
145
|
+
auth = load_auth || raise("Anthropic OAuth login not found")
|
|
146
|
+
refresh_token = auth.fetch("tokens", {}).fetch("refresh_token", nil)
|
|
147
|
+
raise "Anthropic OAuth refresh token not found" if refresh_token.to_s.empty?
|
|
148
|
+
|
|
149
|
+
response = post_json(TOKEN_URL,
|
|
150
|
+
grant_type: "refresh_token",
|
|
151
|
+
client_id: client_id,
|
|
152
|
+
refresh_token: refresh_token)
|
|
153
|
+
refreshed = parse_successful_json(response, "Anthropic OAuth token refresh")
|
|
154
|
+
save_auth(tokens: auth.fetch("tokens", {}).merge(refreshed))
|
|
155
|
+
load_auth
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
def current_auth
|
|
161
|
+
auth = load_auth
|
|
162
|
+
tokens = auth&.fetch("tokens", {}) || {}
|
|
163
|
+
return nil if tokens.empty?
|
|
164
|
+
|
|
165
|
+
if token_expired?(auth) && !tokens["refresh_token"].to_s.empty?
|
|
166
|
+
auth = refresh!
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
auth
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def load_auth
|
|
173
|
+
return nil unless File.exist?(@auth_path)
|
|
174
|
+
|
|
175
|
+
JSON.parse(File.read(@auth_path))
|
|
176
|
+
rescue JSON::ParserError
|
|
177
|
+
nil
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def client_id
|
|
181
|
+
return @client_id unless @client_id.to_s.strip.empty?
|
|
182
|
+
|
|
183
|
+
value = ENV["ANTHROPIC_OAUTH_CLIENT_ID"].to_s.strip
|
|
184
|
+
return value unless value.empty?
|
|
185
|
+
|
|
186
|
+
value = ConfigFiles.config_value(ConfigFiles.read_config(@config_path), "anthropic_oauth_client_id")
|
|
187
|
+
return value unless value.to_s.empty?
|
|
188
|
+
|
|
189
|
+
DEFAULT_CLIENT_ID
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def generate_pkce
|
|
193
|
+
verifier = random_urlsafe(64)
|
|
194
|
+
challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
|
|
195
|
+
{ verifier: verifier, challenge: challenge }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def random_urlsafe(bytes)
|
|
199
|
+
Base64.urlsafe_encode64(SecureRandom.random_bytes(bytes), padding: false)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def start_callback_server
|
|
203
|
+
TCPServer.new("localhost", Integer(ENV.fetch("KWARD_ANTHROPIC_OAUTH_PORT", DEFAULT_PORT)))
|
|
204
|
+
rescue Errno::EADDRINUSE
|
|
205
|
+
TCPServer.new("localhost", 0)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def wait_for_callback(server, expected_state:, timeout_seconds:)
|
|
209
|
+
ready = IO.select([server], nil, nil, timeout_seconds)
|
|
210
|
+
return nil unless ready
|
|
211
|
+
|
|
212
|
+
socket = server.accept
|
|
213
|
+
request_line = socket.gets.to_s
|
|
214
|
+
path = request_line.split[1].to_s
|
|
215
|
+
params = URI.decode_www_form(URI.parse(path).query.to_s).to_h
|
|
216
|
+
|
|
217
|
+
code = nil
|
|
218
|
+
status = "200 OK"
|
|
219
|
+
body = "Login complete. You can close this window."
|
|
220
|
+
if params["error"]
|
|
221
|
+
body = "Login failed. Return to the terminal."
|
|
222
|
+
elsif params["state"].to_s != expected_state.to_s
|
|
223
|
+
status = "400 Bad Request"
|
|
224
|
+
body = "Invalid OAuth state. Return to the terminal."
|
|
225
|
+
else
|
|
226
|
+
code = params["code"]
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
socket.write("HTTP/1.1 #{status}\r\nContent-Type: text/plain\r\nContent-Length: #{body.bytesize}\r\n\r\n#{body}")
|
|
230
|
+
code
|
|
231
|
+
rescue URI::InvalidURIError
|
|
232
|
+
nil
|
|
233
|
+
ensure
|
|
234
|
+
socket&.close
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def exchange_code_for_tokens(code:, redirect_uri:, code_verifier:)
|
|
238
|
+
response = post_json(TOKEN_URL,
|
|
239
|
+
grant_type: "authorization_code",
|
|
240
|
+
client_id: client_id,
|
|
241
|
+
code: code,
|
|
242
|
+
redirect_uri: redirect_uri,
|
|
243
|
+
code_verifier: code_verifier)
|
|
244
|
+
parse_successful_json(response, "Anthropic OAuth token exchange")
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def post_json(uri, params)
|
|
248
|
+
request = Net::HTTP::Post.new(uri)
|
|
249
|
+
request["Content-Type"] = "application/json"
|
|
250
|
+
request["Accept"] = "application/json"
|
|
251
|
+
request.body = JSON.dump(params)
|
|
252
|
+
|
|
253
|
+
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def token_expired?(auth)
|
|
257
|
+
expires_at = auth&.fetch("expires_at", nil)
|
|
258
|
+
return false unless expires_at
|
|
259
|
+
|
|
260
|
+
Time.parse(expires_at) <= Time.now.utc + 60
|
|
261
|
+
rescue ArgumentError
|
|
262
|
+
false
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def expires_at_for(tokens)
|
|
266
|
+
expires_in = tokens["expires_in"] || tokens[:expires_in]
|
|
267
|
+
return tokens["expires_at"] || tokens[:expires_at] unless expires_in
|
|
268
|
+
|
|
269
|
+
(Time.now.utc + expires_in.to_i).iso8601
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def parse_successful_json(response, label)
|
|
273
|
+
raise "#{label} failed with HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
274
|
+
|
|
275
|
+
JSON.parse(response.body)
|
|
276
|
+
rescue JSON::ParserError
|
|
277
|
+
raise "#{label} returned invalid JSON"
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def open_url(url)
|
|
281
|
+
command = if RUBY_PLATFORM.match?(/darwin/)
|
|
282
|
+
"open"
|
|
283
|
+
elsif RUBY_PLATFORM.match?(/linux/)
|
|
284
|
+
"xdg-open"
|
|
285
|
+
end
|
|
286
|
+
return false unless command
|
|
287
|
+
|
|
288
|
+
system(command, url, out: File::NULL, err: File::NULL)
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
data/lib/kward/auth/file.rb
CHANGED
|
@@ -5,7 +5,9 @@ require "uri"
|
|
|
5
5
|
require_relative "file"
|
|
6
6
|
require_relative "../config_files"
|
|
7
7
|
|
|
8
|
+
# Namespace for the Kward CLI agent runtime.
|
|
8
9
|
module Kward
|
|
10
|
+
# OAuth helper for GitHub Copilot credentials.
|
|
9
11
|
class GithubOAuth
|
|
10
12
|
DEVICE_CODE_URL = URI("https://github.com/login/device/code")
|
|
11
13
|
TOKEN_URL = URI("https://github.com/login/oauth/access_token")
|
|
@@ -21,6 +23,7 @@ module Kward
|
|
|
21
23
|
|
|
22
24
|
attr_reader :auth_path
|
|
23
25
|
|
|
26
|
+
# Creates an object for GitHub Copilot OAuth credentials.
|
|
24
27
|
def initialize(auth_path: GithubOAuth.default_auth_path, config_path: ConfigFiles.config_path)
|
|
25
28
|
@auth_path = File.expand_path(auth_path)
|
|
26
29
|
@config_path = File.expand_path(config_path)
|
|
@@ -8,7 +8,9 @@ require "time"
|
|
|
8
8
|
require "uri"
|
|
9
9
|
require_relative "file"
|
|
10
10
|
|
|
11
|
+
# Namespace for the Kward CLI agent runtime.
|
|
11
12
|
module Kward
|
|
13
|
+
# OAuth helper for ChatGPT/OpenAI Codex credentials.
|
|
12
14
|
class OpenAIOAuth
|
|
13
15
|
ISSUER = "https://auth.openai.com"
|
|
14
16
|
TOKEN_URL = URI("#{ISSUER}/oauth/token")
|
|
@@ -19,6 +21,7 @@ module Kward
|
|
|
19
21
|
|
|
20
22
|
attr_reader :auth_path
|
|
21
23
|
|
|
24
|
+
# Creates an object for OpenAI OAuth credentials.
|
|
22
25
|
def initialize(auth_path: OpenAIOAuth.default_auth_path, client_id: nil, config_path: OpenAIOAuth.default_config_path, issuer: ISSUER)
|
|
23
26
|
@auth_path = File.expand_path(auth_path)
|
|
24
27
|
@client_id = client_id
|
|
@@ -143,6 +146,7 @@ module Kward
|
|
|
143
146
|
AuthFile.write_json(@auth_path, data)
|
|
144
147
|
end
|
|
145
148
|
|
|
149
|
+
# Performs refresh for OpenAI OAuth credentials.
|
|
146
150
|
def refresh!
|
|
147
151
|
auth = load_auth || raise("OpenAI OAuth login not found")
|
|
148
152
|
refresh_token = auth.fetch("tokens", {}).fetch("refresh_token", nil)
|
data/lib/kward/cancellation.rb
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
require "thread"
|
|
2
2
|
|
|
3
|
+
# Namespace for the Kward CLI agent runtime.
|
|
3
4
|
module Kward
|
|
5
|
+
# Cooperative cancellation token shared by model calls, tools, and workers.
|
|
4
6
|
class Cancellation
|
|
7
|
+
# Cooperative cancellation token shared by model calls, tools, and workers.
|
|
5
8
|
class CancelledError < StandardError; end
|
|
6
9
|
|
|
7
10
|
def initialize
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Command-line frontend that coordinates terminal interaction, sessions, tools, and model turns.
|
|
4
|
+
class CLI
|
|
5
|
+
# Login, logout, and credential-status commands mixed into the CLI frontend.
|
|
6
|
+
module AuthCommands
|
|
7
|
+
def handle_auth_command(arguments)
|
|
8
|
+
if help_option_arguments?(arguments)
|
|
9
|
+
print_command_help("auth")
|
|
10
|
+
return
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
case arguments
|
|
14
|
+
when ["status"]
|
|
15
|
+
print_auth_status
|
|
16
|
+
when ["logout"]
|
|
17
|
+
logout_auth
|
|
18
|
+
else
|
|
19
|
+
raise ArgumentError, command_usage("auth")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Writes the auth status output for the terminal CLI flow.
|
|
24
|
+
def print_auth_status
|
|
25
|
+
config = safely_read_config.to_h
|
|
26
|
+
lines = ["#{colored("Auth Status", :green, :bold)}", ""]
|
|
27
|
+
lines << auth_status_line("OpenAI OAuth", File.exist?(OpenAIOAuth.default_auth_path), OpenAIOAuth.default_auth_path)
|
|
28
|
+
lines << auth_status_line("Anthropic OAuth", File.exist?(AnthropicOAuth.default_auth_path), AnthropicOAuth.default_auth_path)
|
|
29
|
+
lines << auth_status_line("GitHub OAuth", File.exist?(GithubOAuth.default_auth_path), GithubOAuth.default_auth_path)
|
|
30
|
+
lines << auth_status_line("OpenRouter API key", !config["openrouter_api_key"].to_s.empty? || !ENV["OPENROUTER_API_KEY"].to_s.empty?, ConfigFiles.config_path)
|
|
31
|
+
@prompt.say lines.join("\n")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def auth_status_line(label, configured, location)
|
|
35
|
+
status = configured ? :ok : :warning
|
|
36
|
+
message = configured ? "configured" : "not configured"
|
|
37
|
+
"#{doctor_mark(status)} #{label}: #{message} (#{location})"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def logout_auth
|
|
41
|
+
removed = []
|
|
42
|
+
[OpenAIOAuth.default_auth_path, AnthropicOAuth.default_auth_path, GithubOAuth.default_auth_path].each do |path|
|
|
43
|
+
next unless File.exist?(path)
|
|
44
|
+
|
|
45
|
+
File.delete(path)
|
|
46
|
+
removed << path
|
|
47
|
+
end
|
|
48
|
+
removed << "OpenRouter API key" if OpenRouterAPIKey.new.logout
|
|
49
|
+
|
|
50
|
+
if removed.empty?
|
|
51
|
+
@prompt.say "No saved credentials found."
|
|
52
|
+
else
|
|
53
|
+
@prompt.say "Removed #{removed.length} saved credential#{removed.length == 1 ? "" : "s"}."
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def login(provider: nil, oauth: nil)
|
|
58
|
+
provider = provider.to_s.downcase
|
|
59
|
+
if provider == "openrouter"
|
|
60
|
+
auth = oauth || OpenRouterAPIKey.new
|
|
61
|
+
path = auth.login(prompt: @prompt)
|
|
62
|
+
@prompt.say("#{colored("Saved", :green, :bold)} OpenRouter API key to #{path}")
|
|
63
|
+
return
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
oauth ||= case provider
|
|
67
|
+
when "github" then GithubOAuth.new
|
|
68
|
+
when "anthropic", "claude" then AnthropicOAuth.new
|
|
69
|
+
else OpenAIOAuth.new
|
|
70
|
+
end
|
|
71
|
+
path = oauth.login(prompt: @prompt)
|
|
72
|
+
name = case provider
|
|
73
|
+
when "github" then "GitHub"
|
|
74
|
+
when "anthropic", "claude" then "Anthropic"
|
|
75
|
+
else "OpenAI"
|
|
76
|
+
end
|
|
77
|
+
@prompt.say("#{colored("Saved", :green, :bold)} #{name} OAuth login to #{path}")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Command-line frontend that coordinates terminal interaction, sessions, tools, and model turns.
|
|
4
|
+
class CLI
|
|
5
|
+
# Top-level command help, option parsing, and command dispatch helpers mixed into the CLI frontend.
|
|
6
|
+
module Commands
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def help_command?
|
|
10
|
+
["help", "--help", "-h"].include?(@argv.first) && @argv.length <= 2
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def version_command?
|
|
14
|
+
["version", "--version", "-v"].include?(@argv.first) && @argv.length == 1
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def help_option_arguments?(arguments)
|
|
18
|
+
arguments.length == 1 && ["help", "--help", "-h"].include?(arguments.first)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def one_shot_prompt_argument
|
|
22
|
+
prompt = @argv.join(" ").strip
|
|
23
|
+
prompt.empty? ? nil : prompt
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Writes the command help output for the terminal CLI flow.
|
|
27
|
+
def print_command_help(command_name = nil)
|
|
28
|
+
if command_name.to_s.empty? || ["--help", "-h"].include?(command_name)
|
|
29
|
+
print_help
|
|
30
|
+
return
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
help = command_help[command_name]
|
|
34
|
+
raise ArgumentError, "Unknown command: #{command_name}" unless help
|
|
35
|
+
|
|
36
|
+
@prompt.say render_command_help(command_name, help)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Writes the help output for the terminal CLI flow.
|
|
40
|
+
def print_help
|
|
41
|
+
command = ->(text) { colored(text, :green, :bold) }
|
|
42
|
+
option = ->(text) { colored(text, :cyan) }
|
|
43
|
+
heading = ->(text) { colored(text, :blue, :bold) }
|
|
44
|
+
|
|
45
|
+
@prompt.say <<~HELP.rstrip
|
|
46
|
+
#{colored("Kward", :green, :bold)} - an extendable CLI coding agent
|
|
47
|
+
|
|
48
|
+
#{heading.call("Usage")}
|
|
49
|
+
#{command.call("kward")} Start an interactive chat
|
|
50
|
+
#{command.call("kward")} #{option.call('"Explain this project"')} Run a one-shot prompt
|
|
51
|
+
#{command.call("kward login")} Sign in or save provider credentials
|
|
52
|
+
#{command.call("kward auth status")} Show saved credential status
|
|
53
|
+
#{command.call("kward init")} Install starter prompts and AGENTS.md
|
|
54
|
+
#{command.call("kward doctor")} Check local Kward setup
|
|
55
|
+
#{command.call("kward pan")} Start Pan mode web UI
|
|
56
|
+
#{command.call("kward rpc")} Start the experimental JSON-RPC backend
|
|
57
|
+
|
|
58
|
+
#{heading.call("Commands")}
|
|
59
|
+
#{command.call("help")} Show this help
|
|
60
|
+
#{command.call("version")} Show the installed Kward version
|
|
61
|
+
#{command.call("login")} [anthropic|openrouter|github] Sign in with OpenAI, Anthropic, OpenRouter, or GitHub
|
|
62
|
+
#{command.call("auth status|logout")} Show or clear saved credentials
|
|
63
|
+
#{command.call("init")} Install starter prompts and AGENTS.md
|
|
64
|
+
#{command.call("doctor")} Check local Kward setup
|
|
65
|
+
#{command.call("stats tokens")} [range] [options] Export local token telemetry as CSV
|
|
66
|
+
#{command.call("pan")} Start Pan mode web UI
|
|
67
|
+
#{command.call("rpc")} Run the JSON-RPC backend for UI clients
|
|
68
|
+
|
|
69
|
+
#{heading.call("Options")}
|
|
70
|
+
#{option.call("--working-directory=PATH")} Run Kward from PATH
|
|
71
|
+
#{option.call("--help")}, #{option.call("-h")} Show this help
|
|
72
|
+
#{option.call("--version")}, #{option.call("-v")} Show the installed version
|
|
73
|
+
|
|
74
|
+
#{heading.call("Examples")}
|
|
75
|
+
#{command.call("kward")}
|
|
76
|
+
#{command.call("kward")} #{option.call('"Review this diff"')}
|
|
77
|
+
#{command.call("git diff | kward")} #{option.call('"Review this diff"')}
|
|
78
|
+
#{command.call("kward login openrouter")}
|
|
79
|
+
#{command.call("kward stats tokens today --bucket hour")}
|
|
80
|
+
|
|
81
|
+
Command names take precedence. Anything else is sent as a one-shot prompt.
|
|
82
|
+
HELP
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def command_help
|
|
86
|
+
{
|
|
87
|
+
"help" => {
|
|
88
|
+
usage: "kward help [command]",
|
|
89
|
+
description: "Show the top-level command overview or help for one command.",
|
|
90
|
+
examples: ["kward help", "kward help pan"]
|
|
91
|
+
},
|
|
92
|
+
"version" => {
|
|
93
|
+
usage: "kward version",
|
|
94
|
+
description: "Show the installed Kward version.",
|
|
95
|
+
examples: ["kward version", "kward --version"]
|
|
96
|
+
},
|
|
97
|
+
"login" => {
|
|
98
|
+
usage: "kward login [anthropic|openrouter|github]",
|
|
99
|
+
description: "Sign in with OpenAI, Anthropic, OpenRouter, or GitHub.",
|
|
100
|
+
examples: ["kward login", "kward login anthropic", "kward login openrouter", "kward login github"]
|
|
101
|
+
},
|
|
102
|
+
"auth" => {
|
|
103
|
+
usage: "kward auth status|logout",
|
|
104
|
+
description: "Show or clear saved provider credentials without printing secrets.",
|
|
105
|
+
examples: ["kward auth status", "kward auth logout"]
|
|
106
|
+
},
|
|
107
|
+
"init" => {
|
|
108
|
+
usage: "kward init",
|
|
109
|
+
description: "Install starter prompts and base AGENTS.md into your config directory.",
|
|
110
|
+
examples: ["kward init"]
|
|
111
|
+
},
|
|
112
|
+
"doctor" => {
|
|
113
|
+
usage: "kward doctor",
|
|
114
|
+
description: "Check local Kward configuration, workspace, auth hints, and writable directories.",
|
|
115
|
+
examples: ["kward doctor", "kward --working-directory ~/code/project doctor"]
|
|
116
|
+
},
|
|
117
|
+
"stats" => {
|
|
118
|
+
usage: "kward stats tokens [range] [--bucket second|minute|hour|day|week|month|year] [--output path]",
|
|
119
|
+
description: "Export local token telemetry as CSV.",
|
|
120
|
+
examples: ["kward stats tokens today", "kward stats tokens today --bucket hour", "kward stats tokens week --output tokens.csv"]
|
|
121
|
+
},
|
|
122
|
+
"pan" => {
|
|
123
|
+
usage: "kward pan",
|
|
124
|
+
description: "Start Pan mode, a minimal LAN web UI with a prompt textarea and transcript.",
|
|
125
|
+
examples: ["kward pan", "kward --working-directory ~/code/project pan"]
|
|
126
|
+
},
|
|
127
|
+
"rpc" => {
|
|
128
|
+
usage: "kward rpc",
|
|
129
|
+
description: "Start the experimental JSON-RPC backend for UI clients.",
|
|
130
|
+
examples: ["kward rpc", "kward --working-directory ~/code/project rpc"]
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def render_command_help(name, help)
|
|
136
|
+
heading = ->(text) { colored(text, :blue, :bold) }
|
|
137
|
+
command = ->(text) { colored(text, :green, :bold) }
|
|
138
|
+
|
|
139
|
+
lines = [
|
|
140
|
+
"#{command.call(name)} - #{help.fetch(:description)}",
|
|
141
|
+
"",
|
|
142
|
+
heading.call("Usage"),
|
|
143
|
+
" #{command.call(help.fetch(:usage))}"
|
|
144
|
+
]
|
|
145
|
+
examples = help.fetch(:examples, [])
|
|
146
|
+
if examples.any?
|
|
147
|
+
lines << ""
|
|
148
|
+
lines << heading.call("Examples")
|
|
149
|
+
examples.each { |example| lines << " #{command.call(example)}" }
|
|
150
|
+
end
|
|
151
|
+
lines.join("\n")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def command_usage(name)
|
|
155
|
+
"Usage: #{command_help.fetch(name).fetch(:usage)}"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Writes the version output for the terminal CLI flow.
|
|
159
|
+
def print_version
|
|
160
|
+
@prompt.say "kward #{VERSION}"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def install_starter_pack
|
|
164
|
+
result = StarterPackInstaller.install
|
|
165
|
+
installed_count = result.installed.length
|
|
166
|
+
skipped_count = result.skipped.length
|
|
167
|
+
@prompt.say("Installed #{installed_count} starter pack file#{installed_count == 1 ? "" : "s"}.")
|
|
168
|
+
@prompt.say("Skipped #{skipped_count} existing starter pack file#{skipped_count == 1 ? "" : "s"}.") if skipped_count.positive?
|
|
169
|
+
rescue StandardError => e
|
|
170
|
+
warn "Failed to install starter pack: #{e.message}"
|
|
171
|
+
exit 1
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def pan_mode?
|
|
175
|
+
@argv.first == "pan"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def extract_global_options(arguments)
|
|
179
|
+
remaining = []
|
|
180
|
+
index = 0
|
|
181
|
+
while index < arguments.length
|
|
182
|
+
argument = arguments[index]
|
|
183
|
+
case argument
|
|
184
|
+
when "--"
|
|
185
|
+
@prompt_delimited = true
|
|
186
|
+
remaining.concat(arguments[(index + 1)..] || [])
|
|
187
|
+
break
|
|
188
|
+
when "--working-directory"
|
|
189
|
+
index += 1
|
|
190
|
+
raise ArgumentError, "Missing value for --working-directory" if index >= arguments.length
|
|
191
|
+
|
|
192
|
+
@working_directory = expanded_working_directory(arguments[index])
|
|
193
|
+
when /\A--working-directory=(.*)\z/
|
|
194
|
+
@working_directory = expanded_working_directory(Regexp.last_match(1))
|
|
195
|
+
else
|
|
196
|
+
remaining << argument
|
|
197
|
+
end
|
|
198
|
+
index += 1
|
|
199
|
+
end
|
|
200
|
+
remaining
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def expanded_working_directory(path)
|
|
204
|
+
value = path.to_s.strip
|
|
205
|
+
raise ArgumentError, "Missing value for --working-directory" if value.empty?
|
|
206
|
+
|
|
207
|
+
expanded = File.expand_path(value)
|
|
208
|
+
raise ArgumentError, "Working directory does not exist: #{expanded}" unless Dir.exist?(expanded)
|
|
209
|
+
raise ArgumentError, "Working directory is not a directory: #{expanded}" unless File.directory?(expanded)
|
|
210
|
+
|
|
211
|
+
expanded
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def with_working_directory
|
|
215
|
+
return yield unless @working_directory
|
|
216
|
+
|
|
217
|
+
Dir.chdir(@working_directory) { yield }
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|